Skip to content
Home ML / AI Build a Simple Image Classifier Without Writing Much Code (Teachable Machine + Export to Next.js)

Build a Simple Image Classifier Without Writing Much Code (Teachable Machine + Export to Next.js)

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Tools → Topic 12 of 12
Use Google's Teachable Machine to train an image classifier in the browser, then export and deploy it in a Next.
🧑‍💻 Beginner-friendly — no prior ML / AI experience needed
In this tutorial, you'll learn
Use Google's Teachable Machine to train an image classifier in the browser, then export and deploy it in a Next.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE
Image Classifier Debug Cheat Sheet
Quick checks when your exported Teachable Machine model misbehaves in the browser.
🟡Model returns NaN or Infinity in prediction probabilities
Immediate ActionThe input tensor has values outside the expected range. Most likely the normalization step is applied twice or not at all.
Commands
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]);
Fix NowRaw pixel values should be 0–255. After normalization, values should be -1.0 to 1.0. If you see values outside these ranges, your normalization is wrong. If raw values are already 0–1 (some image loading libraries pre-normalize), skip the div(127.5).sub(1.0) step.
🟡GPU memory grows with each prediction until the browser tab crashes
Immediate ActionTensors are not being disposed after use. Every tensor operation allocates GPU memory that is never automatically freed.
Commands
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();
Fix NowWrap all synchronous tensor operations in tf.tidy(). For async operations (like result.data()), tf.tidy cannot help — you must manually call result.dispose() after extracting the data. The numTensors count should stay constant between predictions. If it grows, you have a leak.
🟡First prediction takes 3–8 seconds, all subsequent predictions are fast (20–50ms)
Immediate ActionThis is normal — the first prediction triggers WebGL shader compilation. The fix is to move this cost to app initialization, not to user interaction.
Commands
const dummy = tf.zeros([1, 224, 224, 3]);
const warmup = model.predict(dummy); warmup.dispose(); dummy.dispose(); console.log('Model warmed up');
Fix NowRun a dummy prediction with a zero tensor during component mount, behind your loading spinner. The user sees 'Loading model…' while warmup runs. By the time they interact with the app, the first real prediction is instant.
Production IncidentInternal Quality Control App Fails Because Training Images Were All Taken Under One Lighting ConditionA manufacturing defect classifier trained on Teachable Machine achieved 98% accuracy during training but only 40% accuracy on the production floor — because the model learned lighting patterns, not defect patterns.
SymptomThe app correctly classified parts in the office demo every time. On the factory floor, it failed consistently and unpredictably. Defect-free parts were flagged as defective, and actual defects were missed. The failure rate was not random — it correlated with time of day (which corresponded to lighting changes from windows and shift lighting).
AssumptionThe team trained the model using 80 images per class taken at their desks under consistent fluorescent overhead lights, with a white desk background, using the same laptop webcam at a fixed angle and distance. They assumed the model would generalize to the factory floor environment because 'the parts look the same everywhere.'
Root causeAll training images shared identical lighting, angle, background, and camera characteristics. The model learned to classify based on lighting gradients and background color patterns — not the physical features of the parts themselves. The factory floor had variable natural light from windows (changing throughout the day), overhead LEDs at different color temperatures, shadows from machinery, different backgrounds (metal conveyor belt vs. white desk), and images captured from a mounted tablet camera at a different distance. The model had never seen any of these conditions during training and had no basis for correct classification.
FixRe-captured 200 images per class directly on the factory floor using the actual mounted tablet camera, across three time periods (morning with natural light, midday under full LEDs, and evening with mixed lighting). Varied the conveyor belt position to include different background sections. Added a dedicated 'background/empty belt' class so the model could distinguish between an empty conveyor and a part. Retrained in Teachable Machine and achieved 91% accuracy in the production environment — a massive improvement from 40%, though the team also added a confidence threshold at 75% to route uncertain predictions to human inspectors.
Key Lesson
Training images must match the deployment environment — lighting, angle, background, camera device, and distance all matter.Capture training data from the actual production context, not a controlled office or lab. If the model will run on a factory tablet, train with images from that tablet.Vary conditions systematically during training: different times of day, different angles, different backgrounds, different distances from the camera.A model that achieves 98% accuracy on lab images and 40% on production images has not learned the task — it has memorized the lab conditions.
Production Debug GuideCommon issues when moving from Teachable Machine training to Next.js deployment — and exactly what to check.
Model loads but predictions are always the same class regardless of input imageThe model is receiving a blank or constant input. Verify that tf.browser.fromPixels() receives a non-black, non-uniform tensor by calling 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.
Predictions work correctly in Teachable Machine preview but are wrong or random in Next.jsThe preprocessing pipeline does not match. Teachable Machine uses MobileNet's expected input format: 224×224 pixels, normalized to [-1, 1]. Check that your code applies exactly: resizeBilinear([224, 224]), toFloat(), div(127.5), sub(1.0), expandDims(0). Missing any one of these steps — especially the normalization range — causes confident but incorrect predictions with no error message.
Model download is slow or fails intermittently in productionThe model.json and weight shard files may be served without compression or from a slow origin. Enable gzip compression (weight files compress to 60–70% of original size). Serve from the same origin as the app to avoid CORS issues. Set Cache-Control: immutable so returning users never re-download. If using a CDN, verify that the .bin file MIME type is not being blocked.
Predictions are accurate in Chrome but wrong or random in SafariSafari's WebGL implementation has different precision characteristics and shader compilation behavior. Check tf.getBackend() — Safari may silently fall back to the CPU backend, which uses different float precision. Also verify that your image element source is not tainted by CORS restrictions (Safari is stricter about cross-origin image tainting). Test on actual iOS Safari, not just desktop Safari.
The app works fine on desktop but crashes on mobile after 30–60 seconds of webcam classificationGPU memory leak from uncleared tensors. Every call to tf.browser.fromPixels() and 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.

training_checklist.txt · TEXT
1234567891011121314151617181920212223242526272829303132333435
Teachable Machine Image ClassificationTraining 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
Mental Model
Transfer Learning in Teachable Machine
You are not training a model from scratch — you are teaching a pre-trained model to recognize new categories using its existing visual understanding.
  • 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).
📊 Production Insight
Teachable Machine does not split your data into train and test sets. Every image you provide is used for training.
The accuracy shown in the preview panel is training accuracy — the model is being tested on images it already memorized.
This number is almost always inflated by 10–30% compared to real-world accuracy.
Rule: hold out 20% of your images per class and test them manually after export. If you trained with 100 images, set aside 20 and test on those. This is the only way to get a realistic accuracy estimate before deployment.
🎯 Key Takeaway
Teachable Machine uses transfer learning on MobileNet — you train only the final classification layer, which is why it takes seconds.
Use 50–200 images per class captured in your actual deployment conditions — lighting, angle, background, and camera device all matter.
Hold out 20% of images for manual testing after export — the Teachable Machine preview shows training accuracy, which is always inflated.
Training Quality Decision Tree
IfAccuracy below 70% in Teachable Machine preview
UseYou need more images or more distinct classes. Add images that show the distinguishing visual features more clearly. Remove blurry or ambiguous images that could belong to multiple classes.
IfAccuracy above 95% in preview but fails on new images
UseThe model is overfitting to your training conditions. Your training images lack diversity — same lighting, same angle, same background. Re-capture images under varied conditions matching your deployment environment.
IfTwo specific classes are frequently confused with each other
UseThese classes share visual features that the model cannot distinguish. Add more images of the distinguishing characteristics — zoom in on the differentiating details. Consider merging the classes if the visual difference is genuinely subtle, or adding a third disambiguation class.
IfModel works perfectly on your laptop webcam but fails on a phone
UseCamera resolution, color profile, and lens distortion differ between devices. Include training images from every device type you plan to deploy on. A laptop webcam and a phone rear camera produce meaningfully different images of the same object.

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.

export_structure.txt · TEXT
123456789101112131415161718192021222324252627
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.
⚠ Shareable Links Are Temporary and Unreliable
The shareable link from Teachable Machine is hosted on Google's infrastructure with no uptime guarantee and no expiration date communicated to you. Google may clean up hosted models at any time. For any deployment that will be used for more than a one-time demo — internal tools, client prototypes, educational projects — download the files and serve them from your own infrastructure. Discovering your production app is broken because Google cleaned up a temporary hosting bucket is a uniquely frustrating experience.
📊 Production Insight
The weights.bin file is typically 2–5 MB for a Teachable Machine model.
On a slow 3G mobile connection, that takes 5–15 seconds to download.
On first visit, the user stares at a blank screen unless you provide feedback.
Rule: show a loading indicator with explicit progress messaging during model download. 'Loading AI model (3.2 MB)…' is far better than a spinner with no context. Users will wait if they know what is happening.
🎯 Key Takeaway
Export as TensorFlow.js format — you get model.json and one or more weight shard .bin files.
Always download and self-host for production use — shareable links are temporary and can expire without warning.
Place model files in the Next.js /public/models/ directory so they are served as static assets at the root path.

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 · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940
// 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
⚠ SSR Breaks TensorFlow.js — Every Time
Never import TensorFlow.js in a server component or without dynamic import with ssr: false. TensorFlow.js accesses browser-specific globals (self, document, navigator, WebGLRenderingContext) during import — not just during use. The import itself crashes in a server environment. The error message is 'self is not defined' or 'navigator is not defined' during build or server-side render. The fix is always the same: add 'use client' at the top of the component file, and wrap the import in dynamic(() => import(...), { ssr: false }) from the parent page.
📊 Production Insight
Next.js re-renders components and unmounts them on route changes. Without cleanup, each navigation creates a new model instance that loads into GPU memory while the old one is never freed.
After 3–5 navigations back and forth, you have 3–5 copies of the model in GPU memory and the tab crashes.
Rule: always dispose the model in the useEffect cleanup function. Use useRef to persist the model instance across re-renders, and dispose it only on unmount.
🎯 Key Takeaway
Always use dynamic import with ssr: false for any component that imports TensorFlow.js.
Add the 'use client' directive at the top of every TF.js component file.
Dispose models in useEffect cleanup to prevent GPU memory leaks on route changes.

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.

components/ImageClassifier.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
'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>
  );
}
💡The Preprocessing Pipeline Must Match Exactly
  • 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.
📊 Production Insight
tf.browser.fromPixels() reads from a rendered DOM element. If the image is not yet visible, not fully loaded, or has zero dimensions, you get a tensor of zeros — and the model will confidently classify a black rectangle.
Always trigger classification from the image element's onload event, not from the file reader's onload.
Rule: verify the image element has non-zero naturalWidth and naturalHeight before calling classifyImage. A zero-size image means the browser has not finished decoding it.
🎯 Key Takeaway
Preprocessing must match MobileNet's pipeline exactly: resize to 224×224, float, normalize to [-1, 1], add batch dimension.
Always warm up the model with a dummy prediction during component mount — never make the user wait for WebGL shader compilation.
Wrap all inference in 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.

components/WebcamClassifier.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
'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>
  );
}
🔥Frame Skipping Prevents GPU Memory Exhaustion
Running inference on every frame at 60 FPS is unnecessary and will exhaust GPU memory on mobile within seconds. Most classification tasks update at 10–15 FPS without any noticeable lag to the user. Use an isProcessingRef flag to skip frames while the previous inference is still running. This naturally throttles to the model's actual inference speed — fast hardware runs more frames, slow hardware runs fewer — without any manual timing code.
📊 Production Insight
Webcam access requires HTTPS in production. navigator.mediaDevices.getUserMedia() silently returns undefined on HTTP origins in most browsers, producing a confusing 'cannot read property of undefined' error.
Always deploy to HTTPS, even for internal tools.
Test on actual mobile devices — front versus rear camera (facingMode: 'user' vs 'environment') affects classification accuracy because they have different focal lengths, color profiles, and fields of view. A model trained on rear camera images may underperform on front camera input.
🎯 Key Takeaway
Use requestAnimationFrame for smooth webcam processing — not setInterval.
Skip frames with an isProcessing flag to prevent GPU memory exhaustion on mobile.
Webcam requires HTTPS in production. Test on both front and rear cameras on real devices.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// 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.
💡File Placement and Caching in Next.js
  • 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.
📊 Production Insight
Without cache headers, the browser re-downloads model files on every page visit — 3–6 MB each time on a route that should be instant.
With immutable caching, the model is cached in the browser for one year after first download. Subsequent visits load the model from disk cache in milliseconds.
Rule: always set Cache-Control: immutable for model weight files. Use directory-based versioning (/models/v1/, /models/v2/) for model updates instead of cache-busting query parameters, which break some CDN configurations.
🎯 Key Takeaway
Host model files in /public/models/ with immutable cache headers for zero-cost repeat visits.
Enable gzip — weight files compress to 60–70% of original size, cutting download time by a third.
First visit costs 2–5 MB of download. Every subsequent visit loads from browser cache at near-zero cost.

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.

components/ClassifierWithFallback.jsx · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
'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 };
}
⚠ Softmax Confidence Scores Are Not Calibrated Probabilities
The softmax output of a neural network looks like a probability distribution — values sum to 1.0 and the highest value is presented as 'confidence.' But these scores are not calibrated. A model outputting 85% confidence does not mean it is correct 85% of the time. In practice, models are often overconfident: they output 90%+ confidence on inputs they have never seen and get completely wrong. Use the threshold as a practical gate — a heuristic that catches obvious low-confidence cases — not as a statistical guarantee. Always validate your threshold choice on a held-out test set by measuring what percentage of errors the threshold actually catches.
📊 Production Insight
A confidence threshold of 70% typically catches 60–80% of misclassifications, depending on the model and data.
Lowering the threshold lets more errors through to the user. Raising it increases the volume of predictions routed to manual review, which costs human time.
This is a business decision, not a technical one: how much manual review budget do you have versus how much error tolerance does the use case allow?
Rule: tune the threshold on your held-out test set by plotting the automation rate (percentage of predictions above threshold) against the error rate (percentage of above-threshold predictions that are wrong). The optimal threshold is where the error rate is acceptable and the automation rate justifies the system's existence.
🎯 Key Takeaway
Add a confidence threshold to route uncertain predictions to manual review instead of showing wrong answers to users.
Softmax scores are not calibrated probabilities — a model can be 95% 'confident' and completely wrong.
The threshold is a business decision: more automation versus fewer errors. Tune it on held-out test data.

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.

deployment_checklist.txt · TEXT
123456789101112131415161718192021222324252627282930313233
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
🔥Browser and Device Support Reality
WebGL is supported in all modern browsers released since 2017. However, Safari on older iOS devices (iPhone 8 and earlier on older iOS versions), some Android WebView implementations in embedded apps, and privacy-focused browsers that disable WebGL by default can cause TensorFlow.js to fall back to the CPU backend. CPU inference works but is 10–50x slower — a 20ms WebGL prediction becomes 500ms–2s on CPU. Always check tf.getBackend() on load, and consider disabling real-time webcam features on CPU-only devices where inference is too slow to be useful.
📊 Production Insight
The single most common deployment failure is forgetting the 'use client' directive.
Next.js App Router treats all components as server components by default. Importing TensorFlow.js in a server component produces 'self is not defined' during build — a confusing error if you have not encountered it before.
Rule: if your component file has import * as tf from '@tensorflow/tfjs' anywhere in it, the first line must be 'use client'. No exceptions.
🎯 Key Takeaway
Test on real devices, not just desktop Chrome — WebGL support, camera behavior, and memory limits vary significantly.
Enable gzip and immutable caching for model files before deployment.
The 'use client' directive and dynamic import with ssr: false are the two most common deployment failure points — verify both.
🗂 Image Classification Approaches Compared
Choose the right approach based on your constraints — accuracy requirements, development time, and deployment infrastructure.
ApproachCode RequiredTraining TimeAccuracy PotentialCostBest For
Teachable Machine + TF.jsMinimal (~100 lines JS)30–120 secondsGood for 5–10 visually distinct classesFree (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 controlGPU 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 domainsPer-request pricing ($1–3 per 1K images)General object labeling, OCR, face detection — no custom classes
Hugging Face + Fine-tuningModerate (~200 lines Python)Minutes to hoursHigh — modern architectures with transfer learningFree tier + optional GPUCustom classifiers with more control than Teachable Machine
ONNX Runtime WebModerate (~150 lines JS)Depends on source modelMatches the source model exactlyFree (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

    Training with fewer than 30 images per class
    Symptom

    Model achieves 95%+ accuracy in the Teachable Machine preview but fails on any image that is not nearly identical to a training example. It has memorized specific images, not learned visual patterns. Change the background slightly and accuracy collapses.

    Fix

    Use 50–200 images per class minimum. Capture images across systematically varied lighting conditions, camera angles, backgrounds, and distances. The more variation in your training data, the better the model generalizes. Twenty images of a cup on a white desk teaches the model to recognize 'white desk' — not 'cup'.

    Not warming up the model before the first user interaction
    Symptom

    User uploads an image and waits 3–8 seconds for the first prediction. Every subsequent prediction is instant (20–50ms). The user experience is confusing and feels broken — 'why is the first one so slow?'

    Fix

    Run a dummy prediction with tf.zeros([1, 224, 224, 3]) during component mount, behind your loading spinner. This forces WebGL shader compilation before the user interacts. By the time the loading state clears, the first real prediction will be instant.

    Importing TensorFlow.js in a Next.js server component
    Symptom

    Build fails with 'self is not defined' or 'navigator is not defined' or 'document is not defined'. The error occurs during server-side rendering because TensorFlow.js accesses browser globals at import time.

    Fix

    Add 'use client' as the first line of the component file. Import the component using dynamic(() => import('./Component'), { ssr: false }) from the parent page. Both steps are required — 'use client' alone is not enough if the parent page server-renders.

    Using the Teachable Machine shareable link in deployed production code
    Symptom

    Model stops working after a few days or weeks. Users see 'Failed to fetch model' errors. The Google-hosted shareable link has expired or been cleaned up — with no warning, no email notification, and no way to restore it.

    Fix

    Download model files during export and serve from /public/models/ in your Next.js project. Set immutable cache headers. The shareable link is for one-time demos and Slack messages — never for deployed applications.

    Not disposing tensors after each prediction
    Symptom

    GPU memory usage grows with every prediction. The tf.memory().numTensors count climbs monotonically. After 50–200 predictions (fewer on mobile), the tab crashes with an out-of-memory error or the browser forcibly kills the tab.

    Fix

    Wrap all synchronous tensor operations in tf.tidy(), which automatically disposes intermediate tensors. For the final async result (after calling .data()), manually call result.dispose() in a .finally() block. Monitor tf.memory().numTensors during development — this number must stay constant across predictions.

    Testing accuracy only in the Teachable Machine preview panel and assuming it reflects real-world performance
    Symptom

    Teachable Machine shows 97% accuracy. The deployed model achieves 55% on actual user inputs. The team is baffled because 'it worked in training.' Stakeholder trust in the project evaporates.

    Fix

    The Teachable Machine preview tests on training images — the model has memorized these. Hold out 20% of your images per class and do not upload them to Teachable Machine. After export, test the deployed model on these held-out images. If accuracy drops more than 10% from preview accuracy, your training data lacks the diversity needed for real-world performance.

Interview Questions on This Topic

  • QWhat is transfer learning, and why does Teachable Machine use it?JuniorReveal
    Transfer learning reuses a model that was trained on a large, general dataset and adapts it to a new, smaller, specific dataset. Teachable Machine uses MobileNet — a convolutional neural network pre-trained on ImageNet's 1.4 million images across 1,000 categories. MobileNet has already learned to detect low-level visual features (edges, textures, shapes) and mid-level patterns (object parts, spatial relationships). Teachable Machine freezes all of MobileNet's pre-trained layers and only retrains the final classification head — a small fully-connected layer that maps MobileNet's learned features to your custom classes. This is why training takes 30–120 seconds instead of hours: only a few hundred parameters are being updated, leveraging all the visual understanding that took Google days of GPU compute to build. The trade-off is that transfer learning works best when your images share visual characteristics with ImageNet's training data — natural photos of objects, clear subjects, reasonable lighting. It works less well on highly specialized visual domains like microscopy, satellite imagery, or X-rays, where the low-level features (edge types, textures) are fundamentally different from natural photos.
  • QHow would you improve the accuracy of a Teachable Machine model that is underperforming in production?Mid-levelReveal
    I would investigate in this specific order, because the most common causes are the cheapest to fix: First, compare the training images to actual production inputs. If training used office lighting and production uses factory lighting, that is the root cause. Recapture 100–200 images per class under actual production conditions using the actual deployment device. Second, check class balance. If one class has 200 images and another has 30, the model is biased toward the over-represented class. Equalize image counts across classes. Third, verify the preprocessing pipeline in the deployed code matches exactly: 224×224 resize, normalize to [-1, 1], batch dimension. A normalization range mismatch (e.g., [0, 1] instead of [-1, 1]) causes confident but wrong predictions with no error. Fourth, add a confidence threshold to filter uncertain predictions and route them to manual review. This does not improve the model, but it prevents wrong predictions from reaching users. Fifth, if accuracy is still insufficient after data improvements, move to a custom TensorFlow or PyTorch training pipeline with data augmentation (random crops, flips, color jitter, rotation), which Teachable Machine does not support. This provides more control over training and typically closes the remaining accuracy gap.
  • QExplain the trade-offs of running image classification in the browser versus on a server.SeniorReveal
    Browser-side inference eliminates network latency (no round-trip to a server), eliminates per-request server costs (no GPU instance, no API billing), keeps image data on-device (important for privacy-sensitive applications like medical or personal photos), and works offline after the model files are cached. The user experience is excellent once the model loads — predictions are instant. The trade-offs are significant for certain use cases: model size is constrained by what fits in browser memory (typically under 50 MB), performance varies wildly across devices (a gaming laptop with a discrete GPU versus a budget Android phone with limited WebGL support), you have no control over the runtime environment (browser version, GPU driver, available memory), and updating the model requires a code deployment rather than a model registry swap. Server-side inference allows arbitrarily large models, provides consistent hardware (you choose the GPU), enables easy model updates without client redeployment, centralizes logging and monitoring, and can process images in batch. But it adds 200ms–2s of network latency per prediction, requires infrastructure (GPU servers, autoscaling, load balancing), sends user data over the network (privacy and compliance concerns), and costs money per request at scale. For narrow classification tasks (5–10 classes) with small models (under 10 MB), browser-side is almost always the better choice. For complex models, high-throughput batch processing, or situations requiring consistent accuracy guarantees, server-side wins.

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.

🔥
Naren Founder & Author

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.

← PreviousMy 2026 Developer Productivity Stack (Tools & Workflow)
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged