Build a Simple Image Classifier Without Writing Much Code (Teachable Machine + Export to Next.js)
- Teachable Machine uses transfer learning on MobileNet — you train only the final classification layer, which is why it takes seconds, not hours.
- Use 50–200 images per class captured in your actual deployment environment. Training conditions must match production conditions — lighting, angle, background, camera device.
- Export as TensorFlow.js format and self-host model files in /public/models/. Shareable links are temporary and will break.
- Teachable Machine trains image classifiers in the browser with zero code — drag and drop images, click Train
- Export trained models as TensorFlow.js format (model.json + weight shards) for direct browser inference
- Load the model in Next.js with @tensorflow/tfjs — runs entirely client-side, no server inference needed
- First prediction compiles WebGL shaders and takes 3–8 seconds — always warm up during app load, not on user interaction
- Production rule: keep classes under 10 and images per class above 50 for usable accuracy
- Biggest mistake: training with 10 images per class under lab conditions and expecting reliable real-world predictions
- Always wrap inference in tf.tidy() — every uncleaned tensor leaks GPU memory and will crash mobile tabs
Model returns NaN or Infinity in prediction probabilities
const tensor = tf.browser.fromPixels(imageElement); console.log('min:', tensor.min().dataSync()[0], 'max:', tensor.max().dataSync()[0]);const normalized = tensor.toFloat().div(127.5).sub(1.0); console.log('normalized min:', normalized.min().dataSync()[0], 'max:', normalized.max().dataSync()[0]);GPU memory grows with each prediction until the browser tab crashes
console.log('Tensors in memory:', tf.memory().numTensors, 'Bytes:', tf.memory().numBytes);const result = tf.tidy(() => { return model.predict(preprocessedInput); }); const data = await result.data(); result.dispose();First prediction takes 3–8 seconds, all subsequent predictions are fast (20–50ms)
const dummy = tf.zeros([1, 224, 224, 3]);const warmup = model.predict(dummy); warmup.dispose(); dummy.dispose(); console.log('Model warmed up');Production Incident
Production Debug GuideCommon issues when moving from Teachable Machine training to Next.js deployment — and exactly what to check.
tensor.print() and inspecting values. If the image element is not yet rendered or has not finished loading, fromPixels returns zeros. Ensure the img element's onload event has fired before running inference.model.predict() allocates GPU memory. On mobile devices with limited VRAM, this fills up in 50–200 frames. Verify every tensor operation is inside tf.tidy(), or that you manually call .dispose() on every intermediate tensor. Monitor with console.log(tf.memory().numTensors) — this number should stay constant across predictions, not grow.Most image classification tutorials require Python, GPU setup, CUDA drivers, and weeks of learning before you get a working prototype. Teachable Machine eliminates all of that. You train in the browser, export a TensorFlow.js model, and load it in your Next.js application. The entire pipeline requires zero Python and zero server-side ML infrastructure.
The exported model runs entirely client-side. No server inference, no API calls, no per-request billing, no latency from network round-trips. The user's browser does all the computation using WebGL. This makes it ideal for prototypes, internal tools, educational projects, and low-traffic applications where standing up a GPU inference server is wildly disproportionate to the problem.
The common misconception is that Teachable Machine produces toy models unsuitable for real use. For narrow classification tasks — 5 to 10 visually distinct classes with quality training images — the accuracy is genuinely production-viable. The constraint is not the tool. It is the quality, quantity, and diversity of your training images. A model trained on 200 varied images per class in the actual deployment environment will outperform one trained on 2,000 images shot under identical lab conditions.
Training Your Model in Teachable Machine
Teachable Machine (teachablemachine.withgoogle.com) provides a browser-based interface for training image classifiers with zero code. You create classes, upload or webcam-capture images, click Train, and get a working model in under two minutes.
Under the hood, the tool uses transfer learning on MobileNet — a lightweight convolutional neural network pre-trained on ImageNet's 1.4 million images. It freezes MobileNet's feature extraction layers and only retrains the final classification head on your images. This is why training takes seconds instead of hours: only a few hundred parameters are being updated, not millions.
The quality of the model depends entirely on your training images. The tool itself is not the bottleneck — your data is. Images captured under a single lighting condition, at a single angle, with a single background will produce a model that works perfectly under that exact condition and fails everywhere else.
Teachable Machine Image Classification — Training Checklist 1. Create classes (one per category you want to detect) - Minimum: 2 classes - Recommended: 3-10 classes - Avoid: more than 15 classes with fewer than 50 images each - Consider: add an "unknown/other" class with diverse non-target images 2. Add images per class - Minimum: 20 images per class (will overfit — demo only) - Recommended: 50-200 images per class - Best: 200+ images with systematically varied conditions - Critical: similar image counts per class (avoid 200 in one, 30 in another) 3. Image quality guidelines — the make-or-break factor - USE THE SAME DEVICE as deployment (phone camera ≠ DSLR ≠ laptop webcam) - Vary angles: front, side, top, 45-degree tilts, slight rotations - Vary lighting: bright daylight, dim indoor, overhead fluorescent, warm LED - Vary backgrounds: plain walls, cluttered desks, outdoors, dark surfaces - Vary distance: close-up, arm's length, across a table - Include edge cases: partially visible objects, slight blur, small size - Include negative examples in your "other" class: things that look similar but are not 4. Click Train Model - Training takes 30-120 seconds in the browser - Preview accuracy appears in the right panel — but this is training accuracy - Test with NEW images that were NOT in the training set - If accuracy is below 70%, add more diverse images before proceeding 5. Export Model - Click 'Export Model' - Select 'TensorFlow.js' tab - Choose 'Download' (not shareable link — those expire) - Download gives you: model.json + weights.bin files - Save these files — you cannot re-download later without retraining
- MobileNet has already learned to detect edges, shapes, textures, colors, and spatial patterns from over 1 million images across 1,000 categories.
- Teachable Machine freezes all of MobileNet's learned feature layers and only retrains the final classification head — a few hundred parameters.
- This is why training takes 30–120 seconds instead of hours or days. The hard work of learning what edges and textures look like was done by Google years ago.
- Your images teach the classification head which combinations of MobileNet's pre-learned features map to your custom classes.
- More diverse training images produce a classification head that relies on the right features (the actual object) rather than the wrong ones (the background, the lighting, the desk).
Exporting the Model
Teachable Machine exports models in TensorFlow.js format, which consists of a model.json file (the graph topology, layer configuration, and metadata including your class labels) and one or more .bin files (the trained weight values, potentially split into multiple shards for large models).
You have two export options: download the files to your machine, or use a hosted shareable link on Google's infrastructure. Always download for anything beyond a one-day demo. The shareable links have no SLA, no guaranteed uptime, and Google can delete them without notice.
Exported Model File Structure: my_model/ model.json — Model architecture + metadata (class labels, input shape) weights.bin — Trained weight values (may be split into multiple shards) Key fields inside model.json: - modelTopology: Neural network graph definition (layers, connections) - weightsManifest: Paths to weight shard files with byte offsets and sizes - userDefinedMetadata: Contains your class labels array (the names you assigned) - format: 'layers-model' (the TensorFlow.js layers API format) File sizes (typical for Teachable Machine export): - model.json: 50-150 KB - weights.bin: 2-5 MB (depends on number of classes) - Total: ~3-6 MB before compression, ~1.5-3 MB with gzip Deployment options: Option A: Download files → place in /public/models/ in your Next.js project Pros: fast, no external dependency, works offline, full control Cons: model updates require redeployment Option B: Use the shareable link directly (fetches from Google CDN) Pros: zero setup, instant sharing Cons: link expires, requires internet, slow in some regions Recommended: Option A for anything beyond a quick demo.
Setting Up the Next.js Project
TensorFlow.js requires special handling in Next.js because it accesses browser-only APIs — WebGL contexts, the document object, navigator.gpu — that do not exist during server-side rendering. Next.js 13+ uses the App Router with React Server Components by default, which means any component is server-rendered unless you explicitly opt out.
Any component that imports TensorFlow.js must be marked with the 'use client' directive and dynamically imported with SSR disabled. This is the single most common deployment failure: forgetting one of these two steps produces a build error that is confusing if you have not seen it before.
// app/page.jsx — Main page with dynamic import to avoid SSR of TF.js import dynamic from 'next/dynamic'; // Dynamic import with ssr: false prevents the component from being // rendered on the server, where browser APIs do not exist. // The loading fallback is shown while the component JS downloads. const ImageClassifier = dynamic( () => import('@/components/ImageClassifier'), { ssr: false, loading: () => ( <div style={{ padding: '2rem', textAlign: 'center' }}> <p>Loading image classifier…</p> <p style={{ fontSize: '0.85rem', color: '#666' }}> Downloading model files (~3 MB) </p> </div> ) } ); export default function Home() { return ( <main style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}> <h1>Image Classifier</h1> <p> Upload an image or use your webcam to classify objects. All processing happens in your browser — nothing is sent to a server. </p> <ImageClassifier /> </main> ); } // --- Installation --- // npm install @tensorflow/tfjs @tensorflow/tfjs-backend-webgl // // Place your exported model files at: // public/models/my_model/model.json // public/models/my_model/weights.bin
Loading and Running the Model
The core component loads the exported Teachable Machine model, preprocesses uploaded images to match MobileNet's expected input format, runs inference, and displays results with confidence scores.
The preprocessing pipeline is critical and must match exactly what Teachable Machine uses internally: resize to 224×224 pixels, convert to float32, normalize from [0, 255] to [-1, 1], and add a batch dimension. Any deviation from this pipeline — wrong size, wrong normalization range, missing batch dimension — produces confident but completely wrong predictions with no error message. The model does not know its input is wrong. It just gives you garbage output.
'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import * as tf from '@tensorflow/tfjs'; import '@tensorflow/tfjs-backend-webgl'; const MODEL_URL = '/models/my_model/model.json'; export default function ImageClassifier() { const [model, setModel] = useState(null); const [labels, setLabels] = useState([]); const [loading, setLoading] = useState(true); const [predictions, setPredictions] = useState([]); const [error, setError] = useState(null); const fileInputRef = useRef(null); const imageRef = useRef(null); const modelRef = useRef(null); // Load model once on mount, dispose on unmount useEffect(() => { let mounted = true; async function init() { try { // Wait for TF.js backend to initialize await tf.ready(); console.log('TF.js backend:', tf.getBackend()); // Load the exported Teachable Machine model const loadedModel = await tf.loadLayersModel(MODEL_URL); // Warm up: first prediction compiles WebGL shaders (3-8s) // Do this now, behind the loading spinner, not on first user action const dummy = tf.zeros([1, 224, 224, 3]); const warmup = loadedModel.predict(dummy); warmup.dispose(); dummy.dispose(); // Extract class labels from model metadata // Teachable Machine stores labels in the model metadata let classLabels = []; try { const metadataUrl = MODEL_URL.replace('model.json', 'metadata.json'); const metadataResponse = await fetch(metadataUrl); if (metadataResponse.ok) { const metadata = await metadataResponse.json(); classLabels = metadata.labels || []; } } catch (metaErr) { console.warn('No metadata.json found, using generic labels'); const outputShape = loadedModel.outputShape; const numClasses = outputShape[outputShape.length - 1]; classLabels = Array.from( { length: numClasses }, (_, i) => `Class ${i}` ); } if (mounted) { setModel(loadedModel); modelRef.current = loadedModel; setLabels(classLabels); setLoading(false); console.log( `Model loaded. ${classLabels.length} classes: ${classLabels.join(', ')}` ); } else { loadedModel.dispose(); } } catch (err) { console.error('Model loading failed:', err); if (mounted) { setError(`Failed to load model: ${err.message}`); setLoading(false); } } } init(); // Cleanup: dispose model on unmount to free GPU memory return () => { mounted = false; if (modelRef.current) { modelRef.current.dispose(); console.log('Model disposed on unmount'); } }; }, []); // Classify an image element const classifyImage = useCallback( async (imgElement) => { if (!model || !imgElement) return; // All tensor ops wrapped in tf.tidy() for automatic memory cleanup const outputTensor = tf.tidy(() => { // Step 1: Read pixel data from the DOM image element const imageTensor = tf.browser.fromPixels(imgElement); // Step 2: Resize to 224x224 (MobileNet's expected input size) const resized = tf.image.resizeBilinear(imageTensor, [224, 224]); // Step 3: Convert to float and normalize to [-1, 1] // MobileNet expects this range, NOT [0, 1] const normalized = resized.toFloat().div(127.5).sub(1.0); // Step 4: Add batch dimension [224,224,3] → [1,224,224,3] const batched = normalized.expandDims(0); // Step 5: Run inference return model.predict(batched); }); // Extract probabilities from GPU tensor to CPU array const probabilities = await outputTensor.data(); outputTensor.dispose(); // Manual dispose for async result // Sort predictions by confidence, highest first const sorted = Array.from(probabilities) .map((prob, i) => ({ label: labels[i] || `Class ${i}`, confidence: (prob * 100).toFixed(1), })) .sort((a, b) => b.confidence - a.confidence); setPredictions(sorted); }, [model, labels] ); // Handle file upload const handleFileUpload = (event) => { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const img = imageRef.current; img.src = e.target.result; // Wait for image to fully load before classifying img.onload = () => classifyImage(img); }; reader.readAsDataURL(file); }; if (loading) { return ( <div style={{ padding: '2rem', textAlign: 'center' }}> <p>Initializing model…</p> <p style={{ fontSize: '0.85rem', color: '#666' }}> This takes a few seconds on first load </p> </div> ); } if (error) return <p style={{ color: 'red' }}>{error}</p>; return ( <div> <input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileUpload} style={{ marginBottom: '1rem' }} /> <img ref={imageRef} alt="Upload preview" style={{ maxWidth: '300px', marginTop: '1rem', display: 'block', borderRadius: '8px', }} /> {predictions.length > 0 && ( <div style={{ marginTop: '1rem' }}> <h3>Predictions</h3> {predictions.map((p, i) => ( <div key={i} style={{ marginBottom: '0.5rem' }}> <strong>{p.label}</strong>: {p.confidence}% <div style={{ height: '8px', background: '#e0e0e0', borderRadius: '4px', marginTop: '4px', }} > <div style={{ width: `${p.confidence}%`, height: '100%', background: i === 0 ? '#4caf50' : '#90caf9', borderRadius: '4px', transition: 'width 0.3s ease', }} /> </div> </div> ))} </div> )} </div> ); }
- Step 1: tf.browser.fromPixels(imgElement) — reads raw pixels as uint8 [0–255].
- Step 2: resizeBilinear([224, 224]) — resize to MobileNet's expected input dimensions.
- Step 3: .toFloat().div(127.5).sub(1.0) — normalize from [0, 255] to [-1, 1]. NOT [0, 1]. This is the most common mistake.
- Step 4: .expandDims(0) — add batch dimension. Model expects shape [1, 224, 224, 3], not [224, 224, 3].
- Skip any of these steps, or use the wrong normalization range, and the model gives confident but completely wrong predictions. There is no error — just garbage output.
tf.tidy() to prevent GPU memory leaks. Manually dispose async results.Adding Webcam Support
Real-time webcam classification captures frames from a video element and runs inference in a continuous loop. The key engineering challenges are frame timing (do not run inference faster than the model can process), memory management (each frame creates tensors that must be cleaned up), and graceful lifecycle management (stop the webcam and cancel animation frames when the component unmounts).
Do not use setInterval for frame capture — requestAnimationFrame synchronizes with the browser's repaint cycle and automatically pauses when the tab is not visible, saving battery on mobile devices.
'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import * as tf from '@tensorflow/tfjs'; export default function WebcamClassifier({ model, labels }) { const videoRef = useRef(null); const [predictions, setPredictions] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const animationRef = useRef(null); const isProcessingRef = useRef(false); const startWebcam = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 224 }, height: { ideal: 224 }, facingMode: 'environment', // Rear camera on mobile }, }); videoRef.current.srcObject = stream; await videoRef.current.play(); setIsStreaming(true); classifyFrame(); } catch (err) { console.error('Webcam access denied or unavailable:', err); // Common causes: no camera, permission denied, HTTP (not HTTPS) } }; const stopWebcam = () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } if (videoRef.current?.srcObject) { videoRef.current.srcObject.getTracks().forEach((track) => track.stop()); videoRef.current.srcObject = null; } setIsStreaming(false); setPredictions([]); }; const classifyFrame = useCallback(() => { // Skip this frame if the previous inference is still running // This prevents GPU memory buildup from queued predictions if (!model || !videoRef.current || isProcessingRef.current) { animationRef.current = requestAnimationFrame(classifyFrame); return; } // Check that video is actually producing frames if ( videoRef.current.readyState < 2 || videoRef.current.videoWidth === 0 ) { animationRef.current = requestAnimationFrame(classifyFrame); return; } isProcessingRef.current = true; // Synchronous tensor ops wrapped in tf.tidy for auto-cleanup const result = tf.tidy(() => { const frame = tf.browser.fromPixels(videoRef.current); const resized = tf.image.resizeBilinear(frame, [224, 224]); const normalized = resized.toFloat().div(127.5).sub(1.0); const batched = normalized.expandDims(0); return model.predict(batched); }); // Async data extraction — must dispose manually result .data() .then((probabilities) => { const sorted = Array.from(probabilities) .map((prob, i) => ({ label: labels[i] || `Class ${i}`, confidence: (prob * 100).toFixed(1), })) .sort((a, b) => b.confidence - a.confidence) .slice(0, 3); setPredictions(sorted); }) .finally(() => { result.dispose(); isProcessingRef.current = false; animationRef.current = requestAnimationFrame(classifyFrame); }); }, [model, labels]); // Cleanup on unmount: stop webcam, cancel animation frame useEffect(() => { return () => stopWebcam(); }, []); return ( <div> <button onClick={isStreaming ? stopWebcam : startWebcam}> {isStreaming ? 'Stop Camera' : 'Start Camera'} </button> <video ref={videoRef} style={{ width: '300px', marginTop: '1rem', display: isStreaming ? 'block' : 'none', borderRadius: '8px', }} playsInline muted /> {predictions.length > 0 && isStreaming && ( <div style={{ marginTop: '1rem' }}> {predictions.map((p, i) => ( <div key={i} style={{ marginBottom: '0.25rem' }}> <strong>{p.label}</strong>: {p.confidence}% </div> ))} </div> )} </div> ); }
Model File Hosting and Optimization
Model files must be served efficiently. The combined model.json and weights.bin files are typically 3–6 MB uncompressed. Without compression, proper caching, and a loading indicator, this creates a poor first-load experience — especially on mobile networks where 3G connections are still common in many markets.
The optimization is straightforward: enable gzip compression (which cuts the download by 30–40%), set immutable cache headers so returning users never re-download, and provide clear loading feedback during the initial download.
// next.config.mjs — Optimize model file serving /** @type {import('next').NextConfig} */ const nextConfig = { // Enable gzip compression for all static assets compress: true, // Custom headers for model files — cache aggressively async headers() { return [ { // Match all files under /models/ source: '/models/:path*', headers: [ { // Cache for 1 year. Model files do not change after deployment. // When you update the model, change the directory name (v1 → v2). key: 'Cache-Control', value: 'public, max-age=31536000, immutable', }, ], }, ]; }, }; export default nextConfig; // --- File placement --- // public/ // models/ // my_model/ // model.json ← Model architecture and metadata // weights.bin ← Trained weight values // metadata.json ← Class labels (if exported separately) // // These files are served at: // /models/my_model/model.json // /models/my_model/weights.bin // /models/my_model/metadata.json // // --- Model versioning --- // When updating the model, use a new directory: // /models/my_model_v2/model.json // Update MODEL_URL in your component to point to the new version. // Old cached versions will not interfere because the path changed.
- Place model.json, weights.bin, and metadata.json in /public/models/my_model/ — Next.js serves /public/ as static assets at the root URL.
- The model URL in your component becomes '/models/my_model/model.json' — no import, no bundling, just a fetch at runtime.
- Set Cache-Control: public, max-age=31536000, immutable — model files do not change after deployment. This eliminates re-downloads on repeat visits.
- Enable gzip compression — weight files are dense binary data that compresses to 60–70% of original size.
- For model updates, use a new directory path (/models/v2/) rather than overwriting files. This prevents cache confusion and allows instant rollback.
Improving Accuracy Beyond Teachable Machine Defaults
When Teachable Machine accuracy is insufficient for your use case, you have three practical options before abandoning the tool entirely: improve your training data (almost always the highest-impact fix), add a classification confidence threshold that routes uncertain predictions to manual review, and implement an 'unknown' class to handle out-of-distribution inputs gracefully.
Most accuracy problems are data problems, not model problems. A Teachable Machine model that fails at 60% accuracy on production data will often hit 90%+ accuracy after recapturing training images under production conditions. The model architecture is rarely the bottleneck for narrow classification tasks.
'use client'; import { useState, useCallback } from 'react'; import * as tf from '@tensorflow/tfjs'; // Minimum confidence to show a definitive prediction. // Below this threshold, the prediction is flagged as uncertain. // Tune this on your held-out test set — not by intuition. const CONFIDENCE_THRESHOLD = 70; export default function ClassifierWithFallback({ model, labels, imageElement, }) { const [result, setResult] = useState(null); const classify = useCallback(async () => { if (!model || !imageElement) return; // Verify image is actually loaded and visible if ( !imageElement.naturalWidth || !imageElement.naturalHeight ) { console.warn('Image not loaded yet — skipping classification'); return; } const prediction = tf.tidy(() => { const tensor = tf.browser .fromPixels(imageElement) .resizeBilinear([224, 224]) .toFloat() .div(127.5) .sub(1.0) .expandDims(0); return model.predict(tensor); }); const probabilities = await prediction.data(); prediction.dispose(); const sorted = Array.from(probabilities) .map((prob, i) => ({ label: labels[i] || `Class ${i}`, confidence: prob * 100, })) .sort((a, b) => b.confidence - a.confidence); const topPrediction = sorted[0]; const secondPrediction = sorted[1]; // Check confidence AND gap to second prediction // A 72% prediction with the second at 71% is very different // from a 72% prediction with the second at 5% const confidenceGap = topPrediction.confidence - (secondPrediction?.confidence || 0); if ( topPrediction.confidence < CONFIDENCE_THRESHOLD || confidenceGap < 15 // Too close between top two classes ) { setResult({ status: 'uncertain', message: `Low confidence (${topPrediction.confidence.toFixed(1)}%) or ambiguous classification. Please review manually.`, topGuesses: sorted.slice(0, 3), }); } else { setResult({ status: 'confident', label: topPrediction.label, confidence: topPrediction.confidence.toFixed(1), allPredictions: sorted, }); } }, [model, labels, imageElement]); return { result, classify }; }
Deployment Checklist
Before shipping your Teachable Machine classifier to production, verify each item below. These are not aspirational best practices — they are the specific items that cause the most common deployment failures. Skipping any one of them produces a bug that is embarrassing in a demo and damaging in a production tool.
Teachable Machine + Next.js Deployment Checklist === Pre-deployment Validation === [ ] Model files downloaded and placed in /public/models/ [ ] Shareable link is NOT used in production code [ ] Model tested on at least 3 device types (desktop Chrome, iOS Safari, Android Chrome) [ ] Accuracy validated on held-out test images that were NOT in the training set [ ] Confidence threshold tested and tuned on the held-out set [ ] WebGL backend verified on all target browsers (check tf.getBackend()) [ ] Model tested with edge case inputs: blank images, rotated, very dark, very bright === Code Quality === [ ] dynamic(() => import(...), { ssr: false }) used for all TF.js components [ ] 'use client' directive present on every file that imports TensorFlow.js [ ] tf.tidy() wraps ALL synchronous tensor operations [ ] Async tensor results (.data()) followed by .dispose() in .finally() [ ] useEffect cleanup disposes model on unmount via modelRef [ ] Model warmup (dummy prediction) runs during component mount behind loading UI [ ] Webcam streams stopped and tracks released on component unmount === Performance === [ ] Gzip compression enabled for model files (next.config.mjs compress: true) [ ] Cache-Control: public, max-age=31536000, immutable set on /models/* paths [ ] Loading indicator with descriptive text shown during model download [ ] First user interaction triggers instant prediction (warmup already complete) [ ] tf.memory().numTensors monitored in dev — stays constant across predictions === Monitoring and Error Handling === [ ] Console logs backend type on init (webgl vs cpu vs wasm) [ ] Error boundary or try/catch wraps model loading [ ] Fallback UI shown for devices without WebGL support [ ] HTTPS enabled (required for webcam access in production) [ ] Image onload event gates classification — never classify a half-loaded image
| Approach | Code Required | Training Time | Accuracy Potential | Cost | Best For |
|---|---|---|---|---|---|
| Teachable Machine + TF.js | Minimal (~100 lines JS) | 30–120 seconds | Good for 5–10 visually distinct classes | Free (runs in browser) | Prototypes, internal tools, education, offline-capable apps |
| Custom TensorFlow/PyTorch (Python) | Extensive (500+ lines) | Hours to days (GPU required) | Highest — full architecture and training control | GPU compute cost ($1–50/run) | Production systems with complex visual domains |
| Cloud Vision API (Google/AWS/Azure) | API calls only (~20 lines) | Zero (pre-trained general model) | High for common objects, low for custom domains | Per-request pricing ($1–3 per 1K images) | General object labeling, OCR, face detection — no custom classes |
| Hugging Face + Fine-tuning | Moderate (~200 lines Python) | Minutes to hours | High — modern architectures with transfer learning | Free tier + optional GPU | Custom classifiers with more control than Teachable Machine |
| ONNX Runtime Web | Moderate (~150 lines JS) | Depends on source model | Matches the source model exactly | Free (runs in browser) | Deploying existing PyTorch or TensorFlow models to the browser |
🎯 Key Takeaways
- Teachable Machine uses transfer learning on MobileNet — you train only the final classification layer, which is why it takes seconds, not hours.
- Use 50–200 images per class captured in your actual deployment environment. Training conditions must match production conditions — lighting, angle, background, camera device.
- Export as TensorFlow.js format and self-host model files in /public/models/. Shareable links are temporary and will break.
- Always use dynamic import with ssr: false and the 'use client' directive for TensorFlow.js components in Next.js.
- Warm up the model with a dummy prediction during component mount — never make the user wait for WebGL shader compilation on their first interaction.
- Wrap every inference call in
tf.tidy()and manually dispose async results. Tensor leaks crash mobile tabs within minutes of continuous use. - Add a confidence threshold to route uncertain predictions to manual review instead of showing wrong answers. Softmax scores are not calibrated probabilities.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is transfer learning, and why does Teachable Machine use it?JuniorReveal
- QHow would you improve the accuracy of a Teachable Machine model that is underperforming in production?Mid-levelReveal
- QExplain the trade-offs of running image classification in the browser versus on a server.SeniorReveal
Frequently Asked Questions
How many images do I need per class in Teachable Machine?
Minimum 20 for a proof-of-concept demo that you show once and throw away. 50–100 for a working prototype that needs to handle some real-world variation. 200+ for a production tool where accuracy actually matters.
The quality and diversity of images matters far more than raw count. Fifty images captured under systematically varied conditions — different lighting, angles, backgrounds, distances, and devices — outperform 200 images taken under identical conditions. The 200 identical images teach the model to recognize the background and lighting, not the object.
Always hold out 20% of your images per class for testing and do not upload them to Teachable Machine. The preview panel tests on training data and always overstates real accuracy.
Can I use Teachable Machine for real-time video classification?
Yes, and it works well. The exported TensorFlow.js model can classify webcam frames in real-time at 10–15 FPS on most modern devices. Use requestAnimationFrame to capture frames from a video element, preprocess each frame to 224×224 with the standard MobileNet normalization, and run inference. Use an isProcessing flag to skip frames while the previous inference is still running — this prevents GPU memory buildup and naturally throttles to the device's actual inference speed.
Webcam access requires HTTPS in production. Test on actual mobile devices — front and rear cameras produce meaningfully different images of the same object.
What happens if the user's browser does not support WebGL?
TensorFlow.js automatically falls back to the CPU backend, which always works but is 10–50x slower. For a 224×224 Teachable Machine model, CPU inference takes 500ms–2s per prediction versus 20–50ms on WebGL.
You can detect the active backend with tf.getBackend() during initialization. For CPU-only devices, consider disabling real-time webcam classification (which requires fast repeated inference) and keeping only single-image upload classification (where a 1-second wait is acceptable). You could also try the WASM backend (@tensorflow/tfjs-backend-wasm), which is typically 2–5x faster than pure CPU on supported browsers.
How do I update my model after deployment without breaking things for existing users?
Retrain in Teachable Machine with improved or additional images, re-export the model, and place the new files in a new directory — /public/models/my_model_v2/ rather than overwriting the v1 files. Update the MODEL_URL constant in your component to point to the new path.
Because you set immutable cache headers, existing users still have the old model cached at the v1 path. New deployments point to the v2 path, so new visitors download the updated model. Old cache entries expire naturally, and no user ever gets a broken or partially-updated model.
Avoid overwriting files at the same path. If you do, some users will have stale model.json cached but download fresh weights.bin, producing a mismatch that causes silent inference failures.
Is Teachable Machine accurate enough for production use?
For narrow classification tasks — 5 to 10 visually distinct classes, captured under controlled but realistic conditions — yes. Internal quality inspection tools, educational apps, accessibility aids, and interactive art installations all use Teachable Machine models successfully in production.
The accuracy ceiling is determined by three factors: how visually distinct your classes are (classifying dogs vs. cats is easier than classifying dog breeds), how well your training images represent real deployment conditions, and how many classes you have (accuracy generally drops as you add more classes with fewer images each).
If you need more than 10 classes, or your classes are visually subtle (defect types that differ by a few pixels), or you need consistent 95%+ accuracy with formal SLAs, move to a custom training pipeline with data augmentation. Teachable Machine is a powerful starting point, not the final destination for every use case.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.