Adam Innes · Blog

Run an AI Text Classifier in the Browser

· 5 min · ai, react, javascript

A feedback box does not always need a round trip to an AI server. If the job is narrow enough, the browser can download a model and run the classification itself. That changes the engineering problem from managing an inference endpoint to managing somebody else’s device, connection, and patience.

I like sentiment classification as a starting point because the output is easy to inspect. Type a short English comment, run a model, and display its predicted sentiment. It is a small feature with enough real constraints to reveal whether browser inference fits the product.

The classifier below predicts positive or negative sentiment. It does not identify refund requests, assign support queues, or decide whether a customer deserves escalation. Those would require a different model and a separate evaluation.

Choose a small, explicit baseline

Hugging Face’s Transformers.js v3 release announcement dates to October 2024, so this is an established option by September 2025. Version 3 added WebGPU support and moved the package to @huggingface/transformers. For this example, I would start with its WebAssembly path and leave GPU experiments until there is a working baseline.

Use an existing client rendered React app with Vite and install the version used here:

npm install --save-exact @huggingface/transformers@3.0.0

The version pin describes this example’s API baseline, not a claim that 3.0.0 is the newest available release. Keep the generated lockfile as well.

The tagged classification implementation documents the sentiment-analysis pipeline and the Xenova/distilbert-base-uncased-finetuned-sst-2-english model used below. Its result contains a label and score. A high score describes the model’s preference among its supported labels; it does not establish that the classification is correct for your business.

Put the model in a worker

Loading and running the model belongs outside the UI thread. Hugging Face’s versioned React tutorial uses a module worker and retains a pipeline instance between requests. The same arrangement works for this smaller classification task.

Create src/classifier.worker.js. The promise is shared across calls, so the worker does not build a new pipeline for every comment. Initialization failures clear the promise so a later click can retry.

import { pipeline } from '@huggingface/transformers';

let classifier;
self.onmessage = async ({ data: text }) => {
  try {
    classifier ??= pipeline(
      'sentiment-analysis',
      'Xenova/distilbert-base-uncased-finetuned-sst-2-english'
    ).catch(error => {
      classifier = undefined;
      throw error;
    });
    const predict = await classifier;
    const start = performance.now();
    const [result] = await predict(text);
    self.postMessage({
      result,
      inferenceMs: performance.now() - start,
    });
  } catch (error) {
    self.postMessage({ error: String(error) });
  }
};

The worker reports inference time after initialization. That distinction matters: the first click also has to obtain model assets and initialize the runtime. Reporting only this timer as the first run experience would hide the part users are most likely to notice.

This is intentionally a single request interface. The React component disables submission during a prediction. A product that processes batches or supports cancellation should define a request protocol with identifiers instead of letting unrelated results race into the same output field.

Connect a minimal React interface

Replace src/App.jsx with the following component. It creates a worker when mounted, removes its callbacks and terminates it during cleanup, and creates a fresh worker on the next mount. That cleanup also accommodates React’s development Strict Mode effect cycle.

import { useEffect, useRef, useState } from 'react';

export default function App() {
  const worker = useRef(null);
  const [text, setText] = useState('The package arrived early.');
  const [busy, setBusy] = useState(false);
  const [output, setOutput] = useState('');

  useEffect(() => {
    const instance = new Worker(
      new URL('./classifier.worker.js', import.meta.url),
      { type: 'module' }
    );
    worker.current = instance;
    instance.onmessage = ({ data }) => {
      setBusy(false);
      setOutput(data.error ??
        `${data.result.label}: ${data.result.score.toFixed(3)} ` +
        `(${data.inferenceMs.toFixed(0)} ms inference)`);
    };
    instance.onerror = () => {
      setBusy(false);
      setOutput('The worker failed. Reload to try again.');
    };
    return () => {
      instance.onmessage = null;
      instance.onerror = null;
      instance.terminate();
      worker.current = null;
    };
  }, []);

  function classify() {
    if (!worker.current || busy || !text.trim()) return;
    setBusy(true);
    setOutput('Loading model or classifying...');
    worker.current.postMessage(text);
  }

  return <main>
    <h1>Feedback sentiment</h1>
    <textarea aria-label="English feedback" value={text}
      onChange={event => setText(event.target.value)} />
    <button disabled={busy || !text.trim()} onClick={classify}>
      Classify
    </button>
    <p role="status">{output}</p>
  </main>;
}

Run the app with its existing npm run dev script. The first classification triggers loading; merely mounting this component does not call the pipeline. The displayed score has three decimal places for readability, not because that level of precision is meaningful to a user.

For a production screen, I would replace the combined loading message with download progress and a separate ready state. The library’s progress callback, demonstrated in the linked React tutorial, provides a starting point. Users should be able to tell the difference between a model arriving over a slow connection and a stalled prediction.

Measure the three experiences separately

Begin with an empty site cache and watch the browser’s Network panel while classifying one short comment. Record transferred bytes and the time from clicking to seeing the answer. Then submit another comment without reloading. Finally, reload and repeat with cached assets available.

Those observations answer different questions. The fresh visit includes downloading and initialization. The second prediction reuses an initialized pipeline. The reload may reuse downloaded files but still needs to initialize a new worker and model. Averaging all three into one latency figure makes the result hard to interpret.

The versioned environment source enables browser caching when the Cache API is available. Availability is not a promise that assets remain forever or that every reload is offline capable. Test the browser and deployment you actually support, including a reload with the network disabled after a successful first run.

I have not included invented download sizes or benchmark results here. The useful numbers come from the pinned model artifacts, runtime, browser, and devices you test. At minimum, try a phone you would expect a customer to own before deciding that a laptop result is acceptable.

Check the privacy claim in the actual app

In this example, the entered text moves from the page to its worker, and the model runs locally. The application still downloads code and model files. Browser inference does not mean the whole page avoids network traffic.

Keep the Network panel open while changing the feedback and running repeated classifications. Inspect analytics, error reporting, and session recording as well as model requests. A third party script that captures textarea contents can undo the privacy benefit even when inference itself is local. Avoid logging the raw comment while debugging timings.

The library supports configuring remote loading, as the environment source shows. Serving reviewed model assets yourself is an option, but it moves responsibility for those files into your deployment. Pinning the JavaScript package alone does not pin a remotely hosted model revision. Resolve and retain the model artifacts you intend to ship before treating the experiment as a reproducible release.

Decide whether the label helps

Try a clearly happy comment, a complaint, and a mixed message such as “The item is great, but the replacement took three weeks.” Have someone label the examples independently, then compare predictions. Include the language and tone customers actually use.

A binary sentiment model has no neutral category just because your interface needs one. It can give an apparently confident answer to a comment that does not fit either label well. I would use this demonstration to explore a local sentiment hint, then evaluate a model trained for the real task before connecting it to consequential workflow decisions.

The appeal is concrete: a small prediction can happen where the text is entered. Whether that is a good product choice depends on download friction, device performance, and useful accuracy. A worker and a pipeline get the mechanism running; those measurements tell you whether to keep it.

← all posts