Author: Cyrus Wong (黃俊彥) — Google Developer Expert - Cloud (AI), Senior Lecturer at HKIIT
1. Problem Statement: The Classroom Trilemma — Integrity vs. Privacy vs. Cloud FinOps
In our computer laboratories at the Hong Kong Institute of Information Technology (HKIIT / VTC), we run hundreds of hands-on Cloud Computing, Cybersecurity, and AI/ML practical sessions every semester. Across higher education globally, every engineering faculty today confronts what we call the Assessment & Classroom Trilemma:
- Academic Integrity in the AI Copilot Era: Unsupervised coding labs and practical examinations are easily bypassed via second monitors, screen swapping, remote desktop tunnels, or external AI chat windows. Traditional multiple-choice quizzes no longer measure whether a student can actually configure a Kubernetes cluster, debug a Docker Compose network, or train a PyTorch model.
- Institutional Cost Sustainability: Commercial proctoring SaaS platforms charge $15 to $25 USD per student per exam ($750–$1,250 USD for a single 50-student cohort). Public universities and vocational institutions simply cannot afford that price tag for daily formative lab exercises, weekly capstone consultations, and continuous assessment.
When we set out to replace legacy proctoring and manual lab grading with an open-source, cloud-native platform in asia-east2 (Hong Kong), we established a strict architectural mandate:
-
Zero Continuous Raw Video/Audio Streaming to the Cloud: 95% of high-frequency biometric gaze, posture, object detection, and speech-intent proctoring must execute 100% locally inside the student's browser using WebGPU and WebAssembly (LiteRT-LM Gemma 4 E2B
gemma-4-E2B-it-web.litertlm, LiteRT Whisper-Tiny, and MediaPipe Tasks-Vision) at $0.00 cloud API cost. -
Right-Sized Frontier Cloud Reasoning: Reserve Google Cloud's frontier models—led by Gemini 4 Argon (
gemini-4-argon), Gemini 3.8 Flash (gemini-3.8-flash), Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite), and Gemini 3.5 Transcribe (gemini-3.5-transcribe-preview)—for event-driven pedagogical synthesis, automated video rubric grading, live multilingual lecture subtitles, and multi-modal capstone supervision. - Scale-to-Zero Serverless FinOps: Total cloud infrastructure cost must stay under $0.02 USD per student-hour during active classes and drop to $0.00 during weekends and semester breaks.
2. Architectural Deep-Dive: The 4-Tier Hybrid Edge-to-Cloud Blueprint
Instead of streaming 50 concurrent 1080p WebRTC video feeds to cloud GPU clusters, we decoupled high-frequency sensory perception (Tier 1 Browser Edge) from low-latency signaling (Tier 2 Firestore & Cloud Storage), domain-isolated serverless compute (Tier 3 Cloud Functions Gen 2 / Cloud Run), and multimodal reasoning (Tier 4 Vertex AI / Google Genkit).
How the 4 Tiers Work Together in a Live Lab
-
Tier 1 (Student Browser Edge): Every frame from the student's webcam is analyzed in a dedicated Web Worker via MediaPipe's 478-point 3D Face Landmarker (including iris rings
468..477), while ambient microphone audio is gated by an RMS energy detector, transcribed locally by LiteRT Whisper-Tiny, and evaluated for exam collusion by LiteRT-LM Gemma 4 E2B (gemma-4-E2B-it-web.litertlm). Raw video and audio frames are immediately garbage-collected in browser RAM unless a sustained (>3.0s) violation occurs. -
Tier 2 (Firebase Real-Time Backbone): Low-frequency screen captures (every 15–30 seconds) and debounced irregularity snapshots are uploaded directly to Google Cloud Storage (
asia-east2) with signed Firebase Security Rules, while lightweight JSON telemetry updates Firestore. -
Tier 3 (7 Cloud Functions Gen 2 Codebases): Event-driven Cloud Run microservices compile 1-FPS time-lapse MP4s via
ffmpeg, enforce minute-by-minute attendance bitmasks, track 9-category storage quotas, and dispatch rate-limited AI grading tasks through Google Cloud Tasks. -
Tier 4 (Gemini 4 & 3 Model Matrix): Teachers can choose Gemini 4 Argon (
gemini-4-argon), Gemini 3.8 Flash (gemini-3.8-flash), or Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite) across every classroom workflow—from live IDE code inspection and practical task rubric synthesis to post-class cohort Map-Reduce video grading.
3. Domain-Driven Isolation Across 7 Cloud Functions Gen 2 Codebases
A classic architectural mistake in serverless Firebase applications is dumping every trigger and callable endpoint into a single monolithic functions/index.js. When we benchmarked a monolithic deployment early in development, we discovered two severe problems:
-
Cold-Start Contamination: Packaging
fluent-ffmpeg,@ffmpeg-installer/ffmpeg,sharp, and@google-cloud/tasksalongside lightweight Google Cloud Identity Platform (GCIP) blocking functions (beforeUserCreated,beforeUserSignedIn) inflated container cold starts above 4.2 seconds—causing noticeable login delays for students scanning classroom QR codes. -
Blast-Radius & Memory Over-Provisioning: Video compilation (
compileVideo) requires 2 vCPUs and 8 GiB of RAM with a 540-second timeout, whereas an attendance heartbeat or CSV property upload needs only 256 MiB of RAM.
In firebase.json, we partitioned our backend into 7 isolated Cloud Functions Gen 2 micro-codebases running on Node.js 22, each with its own independent package.json, dependency tree, memory profile, and CI/CD deployment guard:
Production Code Listing 1: Multi-Codebase & Hosting Build Guard in firebase.json
Notice how firebase.json not only isolates the 7 nodejs22 function codebases, but also enforces a predeploy environment verification guard on Firebase Hosting so a developer can never accidentally deploy a dev-configured React bundle to the production project (it114115-2627), while serving .wasm binaries with proper MIME headers for MediaPipe and LiteRT:
{
"functions": [
{
"source": "functions/ai_flows",
"codebase": "ai-flows",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
},
{
"source": "functions/media_processing",
"codebase": "media-processing",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
},
{
"source": "functions/property_processing",
"codebase": "property-processing",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
},
{
"source": "functions/scheduled_tasks",
"codebase": "scheduled-tasks",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
},
{
"source": "functions/auth_triggers",
"codebase": "auth-triggers",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
},
{
"source": "functions/storage_triggers",
"codebase": "storage-triggers",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
},
{
"source": "functions/attendance",
"codebase": "attendance",
"runtime": "nodejs22",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]
}
],
"hosting": {
"public": "web-app/dist",
"predeploy": [
"grep -q \"${GCLOUD_PROJECT}.firebaseapp.com\" web-app/dist/assets/index-*.js 2>/dev/null || (echo \"⚠️ [Hosting Guard] Rebuilding web-app/dist for ${GCLOUD_PROJECT}...\" && ./switch-env.sh \"${GCLOUD_PROJECT}\" && if [ \"${GCLOUD_PROJECT}\" = \"it114115-2627\" ]; then npm --prefix web-app run build:prod; else npm --prefix web-app run build:dev; fi)",
"grep -q \"${GCLOUD_PROJECT}.firebaseapp.com\" web-app/dist/assets/index-*.js && echo \"✅ [Hosting Guard] Verified web-app/dist matches ${GCLOUD_PROJECT}.\""
],
"headers": [
{
"source": "**/*.wasm",
"headers": [{ "key": "Content-Type", "value": "application/wasm" }]
},
{
"source": "/index.html",
"headers": [{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }]
}
]
}
}
4. Implementation Walkthrough: Defeating the Firestore $O(N^2)$ Read Explosion & Protecting Operational State
In a naive real-time classroom application, if 50 students write their telemetry every 5 seconds to individual documents (classes/{classId}/students/{studentUid}) and 3 instructors/TAs open the live monitoring dashboard, a single 3-hour practical exam can trigger hundreds of thousands of Firestore document reads and cause React state thrashing on the teacher's browser.
We solved this with a dual-path telemetry design connecting useFaceMonitor.js, useClientLiteRTGemma.js, and functions/property_processing/index.mjs:
Edge Case 1: Preventing Teacher CSV Uploads from Wiping Live Exam State
During live classes, teachers frequently upload a revised CSV spreadsheet (to assign lab seats, project group numbers, or GitHub usernames) into classes/{classId}/studentProperties/{uid} using Sync Mode (which deletes columns removed from the spreadsheet). Early on, we realized a critical hazard: studentProperties/{uid} also stores real-time operational state—such as activeBingo, strikeNumber, passkey, examReadiness, and attention telemetry!
If Sync Mode blindly deleted every field not present in the teacher's CSV headers, uploading a seat chart mid-exam would wipe out students' WebAuthn Passkey bindings and active Bingo challenges.
Production Code Listing 2: Protected Internal Key Guard in functions/property_processing/index.mjs
Here is how isInternalPropertyKey and batched 450-document writes in functions/property_processing/index.mjs guarantee that teacher spreadsheet syncs never clobber live telemetry or authentication state:
/**
* Identifies internal system/operational keys and profile metadata
* that should never be overwritten or deleted by custom property uploads.
*/
export const isInternalPropertyKey = (key) => {
if (!key || typeof key !== 'string') return true;
const lower = key.toLowerCase().trim();
const internalPrefixes = [
'activebingo', 'pendingretry', 'priormissed', 'retrybingo',
'retrydelay', 'lastretry', 'lastbingo', 'retrycancelled',
'passkey', 'lastinattentive', 'inattentive', 'attention',
'lastattention', 'lastactivity', 'lastseen', 'presence',
];
if (internalPrefixes.some((prefix) => lower === prefix || lower.startsWith(prefix))) {
return true;
}
const exactInternalKeys = new Set([
'strikenumber', 'bingostats', 'examreadiness', 'retrycancelledreason',
'passkeybypass', 'lastinattentiveat', 'status', 'updatedat',
'createdat', 'studentname', 'nickname', 'programme', 'program',
'studentclass', 'cohort', 'email', 'studentemail',
]);
return exactInternalKeys.has(lower);
};
When processPropertyUpload runs in sync mode, it strips UTF-8 BOM markers from Excel-exported CSVs, resolves missing student UIDs in 100-user chunks via Firebase Auth (auth.getUsers), and selectively deletes only obsolete custom columns while leaving every internal operational key untouched:
// In sync mode: if an existing custom property is NOT in the uploaded columns,
// delete it so that removing columns from the spreadsheet deletes them in Firestore.
if (isSyncMode) {
for (const existingKey of currentKeys) {
if (!uploadedPropertyKeys.includes(existingKey)) {
updatePayload[existingKey] = FieldValue.delete();
}
}
}
const docRef = db.collection('classes').doc(classId).collection('studentProperties').doc(uid);
batch.set(docRef, updatePayload, { merge: true });
5. Edge Cases & Production Failure Modes We Solved: Zero-Orphan Cascading Storage Purge
Another silent cost killer in educational cloud platforms is orphaned media storage. Over a 15-week semester, a single class generates thousands of periodic WebP/JPEG screenshots, student audio clips, 1-FPS compiled MP4 videos, lecture recordings, .vtt/.srt subtitle tracks, and 720p capstone demo videos across 13 Cloud Storage prefixes and 27+ Firestore collections/subcollections.
If a teacher deletes an old class document (classes/{classId}) or shortens the class's data-retention window (retentionDays / videoRetentionDays), standard Firestore deletion leaves every subcollection document and gigabytes of Cloud Storage blobs behind—accumulating perpetual storage bills and violating PDPO data-retention commitments.
Production Code Listing 3: 4-Stage Cascading Class Purge in functions/storage_triggers/cleanupTriggers.js
In functions/storage_triggers/cleanupTriggers.js, we implemented onClassDocDeleted and onClassRetentionUpdated. Whenever a class document is deleted, onClassDocDeleted executes a 4-stage cascading cleanup across Cloud Storage and Firestore in 500-document batches:
export const onClassDocDeleted = onDocumentDeleted({
document: 'classes/{classId}',
region: FUNCTION_REGION,
timeoutSeconds: 540,
memory: '512MiB',
}, async (event) => {
const classId = event.params.classId;
logger.info(`Class ${classId} was deleted. Commencing complete cascading purge...`);
// Stage 1: Purge all 13 physical Cloud Storage prefixes for this class
const bucket = storage.bucket();
const prefixes = [
`screenshots/${classId}/`, `videos/${classId}/`, `zips/${classId}/`,
`audio/${classId}/`, `recordings/${classId}/`, `subtitles/${classId}/`,
`irregularities/${classId}/`, `classes/${classId}/`, `tasks/${classId}/`,
`submissions/${classId}/`, `reports/${classId}/`, `taskDemos/${classId}/`,
`projectMeetings/${classId}/`
];
for (const prefix of prefixes) {
await bucket.deleteFiles({ prefix, force: true }).catch((err) =>
logger.warn(`Could not purge prefix ${prefix}:`, err)
);
}
// Stage 2: Purge 11 root Firestore collections matching classId in 500-doc batches
const collectionsToClean = [
'screenshots', 'audio', 'videoJobs', 'zipJobs', 'videoAnalysisJobs',
'irregularities', 'progress', 'propertyUploadJobs',
'lectureMergeJobs', 'lectureSubtitleJobs', 'aiJobs'
];
for (const col of collectionsToClean) {
await deleteCollectionByClassId(col, classId);
}
// Stage 3: Purge 19 class subcollections + nested task submissions
const subcollectionsToPurge = [
'projectMeetings', 'projectGroups', 'projectStudentProfiles',
'activeMeetingCaptures', 'lectureRecordings', 'bingoSessions',
'status', 'lessons', 'bingoRecords', 'attendanceAdjustments',
'studentProperties', 'classProperties', 'passkeyBypassRequests',
'screenBroadcastViewers', 'messages', 'audio_audits',
'irregularities', 'questionBank'
];
for (const subcol of subcollectionsToPurge) {
await deleteSubcollectionByPath(`classes/${classId}/${subcol}`);
}
// Stage 4: Unlink deleted classId from teacherProfiles & studentProfiles arrays
await removeClassFromProfiles('teacherProfiles', classId);
await removeClassFromProfiles('studentProfiles', classId);
});
Furthermore, whenever an individual screenshots/{id}, audio/{id}, videoJobs/{id}, or zipJobs/{id} document expires via Firestore Native TTL (expireAt), dedicated onDocumentDeleted triggers (onScreenshotDocDeleted, onAudioDocDeleted, onVideoJobDocDeleted) immediately delete the corresponding physical blob in Cloud Storage—guaranteeing zero orphaned storage bytes.
Production Code Listing 4: 9-Category Real-Time Storage Quota Enforcement & Self-Healing Drift Guard in functions/storage_triggers/storageQuota.js
In addition to cascading deletion, every physical blob uploaded to Cloud Storage is tracked in real time across 9 distinct storage categories (storageUsageScreenShots, storageUsageVideos, storageUsageRecordings, storageUsageAudio, storageUsageIrregularities, storageUsageZips, storageUsageTasks, storageUsageReports, and storageUsageProjectMeetings). If an upload pushes a class over its configured storageQuota, updateStorageUsageOnUpload in functions/storage_triggers/storageQuota.js automatically deletes the offending blob and rolls back the atomic FieldValue.increment counter:
export function getStorageCategoryAndClass(filePath) {
if (!filePath || typeof filePath !== 'string') return null;
const parts = filePath.split('/');
if (parts.length < 3) return null;
if (parts[0] === 'classes') {
return { classId: parts[1], usageField: 'storageUsageTasks' };
}
const classId = parts[1];
if (parts[0] === 'screenshots') return { classId, usageField: 'storageUsageScreenShots' };
if (parts[0] === 'videos') return { classId, usageField: 'storageUsageVideos' };
if (parts[0] === 'recordings' || parts[0] === 'subtitles') return { classId, usageField: 'storageUsageRecordings' };
if (parts[0] === 'audio') return { classId, usageField: 'storageUsageAudio' };
if (parts[0] === 'irregularities') return { classId, usageField: 'storageUsageIrregularities' };
if (parts[0] === 'zips') return { classId, usageField: 'storageUsageZips' };
if (parts[0] === 'tasks' || parts[0] === 'submissions') return { classId, usageField: 'storageUsageTasks' };
if (parts[0] === 'reports') return { classId, usageField: 'storageUsageReports' };
if (parts[0] === 'projectMeetings') return { classId, usageField: 'storageUsageProjectMeetings' };
return null;
}
// Inside updateStorageUsageOnUpload (onObjectFinalized):
if (quota > 0 && usage > quota) {
console.log(`Quota exceeded for class ${classId}. Usage: ${usage}, Quota: ${quota}. Deleting file: ${filePath}`);
await adminStorage.bucket(event.bucket).file(filePath).delete();
await storageRef.update({
storageUsage: FieldValue.increment(-fileSize),
[usageField]: FieldValue.increment(-fileSize),
});
}
6. Production FinOps & Performance Benchmarks (asia-east2 Hong Kong)
By executing continuous biometric vision and speech-intent classification inside the student's browser (Gemma 4 E2B + Whisper-Tiny + MediaPipe) and invoking Gemini 4 Argon (gemini-4-argon), Gemini 3.8 Flash (gemini-3.8-flash), and Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite) strictly on compact, event-driven artifacts, here is our verified production cost breakdown for a 50-student, 2-hour practical examination:
| Cost Component | Commercial Proctoring SaaS | Google AI Classroom (asia-east2) |
Cost Reduction |
|---|---|---|---|
| Live Biometric Face, Gaze & Phone Proctoring | Cloud GPU Video Streaming | On-Device MediaPipe Web Worker ($0.00) | 100% |
| Ambient Speech Intent Classification | Cloud Audio Streaming | On-Device Whisper + Gemma 4 E2B ($0.00) | 100% |
| Firestore Signaling & Telemetry | Dedicated WebSockets Cluster | ~12,000 reads / 8,000 writes (~$0.02) | 99.9% |
| Cloud Run FFmpeg 1-FPS MP4 Compilation | Raw Video Storage (50+ GB) | 50 $\times$ 18 MB MP4s (~$0.18 compute + storage) | 95.0% |
| Automated Rubric Video Grading (50 Students) | Manual Grader (25 Hours) |
gemini-3.8-flash ($0.52) or gemini-4-argon ($1.85) |
99.8% |
| Idle Cost Outside Scheduled Class Hours | Annual Seat License Lock-In | 100% Scale-to-Zero ($0.00 / month) | 100% |
| Total Per-Session Institutional Cost | $750.00 – $1,250.00 USD | $0.72 – $2.05 USD (<$0.02 / student-hr) | 99.8% Savings |
7. Key Takeaways for Google Cloud & EdTech Builders
- Push High-Frequency Perception to the Browser Edge: Never stream continuous 30-FPS video or raw microphone audio to cloud servers when WebGPU and WebAssembly can run LiteRT-LM Gemma 4 E2B and MediaPipe locally on the student's machine at $0.00 cost and zero biometric privacy exposure.
-
Split Monolithic Cloud Functions by Resource Profile: Isolating heavy
ffmpeg/sharpworkloads (media-processing, 8 GiB RAM) from authentication (auth-triggers, 256 MiB RAM) and Genkit AI orchestration (ai-flows, 1 GiB RAM) infirebase.jsoneliminates cold-start latency and slashes container memory billing. -
Give Teachers Model Choice Across Every Workflow: By exposing Gemini 4 Argon (
gemini-4-argon), Gemini 3.8 Flash (gemini-3.8-flash), and Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite) in every UI picker, educators can usegemini-3.5-flash-litefor sub-second live interactions and switch togemini-4-argonwhen grading complex multi-step code or synthesizing new rubrics. -
Automate Data Lifecycle & Cascading Storage Purges: Pairing Firestore Native TTL (
expireAt) with cascadingonDocumentDeletedStorage triggers incleanupTriggers.jsensures strict compliance with Hong Kong PDPO and GDPR while preventing silent storage bill creep.






Top comments (0)