DEV Community

Cover image for I built a self-hosted AI translation engine — here's what worked, what didn't, and the numbers behind every decision
Henrique Rodrigues
Henrique Rodrigues

Posted on

I built a self-hosted AI translation engine — here's what worked, what didn't, and the numbers behind every decision

I've been studying applied AI by building real projects instead of just following tutorials. This post is about the one I'm proudest of: Transdom, an open-source, self-hosted engine that translates any web page in real time using AI models — no per-word API billing, no third party holding your data.

But this isn't a "look what I built" post. It's a "here's what I measured, what I got wrong, and what I reversed" post — because that's the part of building with AI that tutorials usually skip.

Repo: github.com/hjdesigner/transdom
npm: npm install transdom

What Transdom actually does

Two pieces, and both are required — the client library has nothing to translate without a server to call:

1. Run the server (Python + FastAPI, self-hosted via Docker — you own the data and the infrastructure cost):

git clone https://github.com/hjdesigner/transdom
cd transdom
docker compose up --build
Enter fullscreen mode Exit fullscreen mode

2. Add the client to your page. transdom.js (with a React hook) scans the page's DOM, sends the text to your running server, and swaps the translation back in — including content added later by a framework re-render, via MutationObserver.

npm install transdom
Enter fullscreen mode Exit fullscreen mode
import { Transdom } from "transdom";

const transdom = new Transdom({
  apiUrl: "http://your-server:8000/translate/batch",
  sourceLang: "en",
  targetLang: "pt",
});

transdom.startAutoTranslate();
Enter fullscreen mode Exit fullscreen mode

That's the whole integration. Everything interesting happens on the server side, and that's what I want to talk about.

Decision 1: quantization, and why "measured" beats "assumed"

The server originally ran translation models straight through PyTorch. It worked, but it was slow and heavy — no surprise, PyTorch is built for flexibility, not for serving a single model type as fast as possible.

I switched the inference engine to CTranslate2 with int8 quantization — a technique that reduces model weights from 32-bit floats to 8-bit integers, trading a small amount of numerical precision for a large drop in memory and a large gain in speed.

I didn't assume this would help. I benchmarked it:

Engine Time for 15 translations
PyTorch (float32) ~5.76s
CTranslate2 (int8) ~0.87s

Roughly a 6x speedup, plus the on-disk model size dropped from ~465MB to ~226MB. I also ran a quality check using BLEU/chrF against a small hand-written reference set, comparing the two engines sentence by sentence. The quantized version didn't lose quality — on my test set, it scored marginally higher (one sentence produced a more natural phrasing than the float32 version did). Quantization was a clear win, backed by numbers, not a guess.

Decision 2: semantic caching — and where it actually pays off

A naive cache only recognizes identical strings. But real UI text repeats with small variations: "You have successfully logged in" and "You have logged in successfully" mean the same thing but are different strings.

I added a second cache layer using sentence embeddings (all-MiniLM-L6-v2) and cosine similarity. Every translated string gets embedded; a new request is compared against cached embeddings, and if the similarity crosses a threshold, the cached translation is reused instead of re-running the model.

def find_similar_translation(source_lang, target_lang, embedding):
    best_match, best_score = None, 0.0
    for (cached_pair, _), entry in semantic_cache.items():
        if cached_pair != (source_lang, target_lang):
            continue
        score = util.cos_sim(embedding, entry["embedding"]).item()
        if score > best_score:
            best_score, best_match = score, entry["translation"]
    return best_match if best_score >= SIMILARITY_THRESHOLD else None
Enter fullscreen mode Exit fullscreen mode

Tested against the two sentences above: similarity score 0.9892, well above my threshold of 0.92, and the cached translation was reused correctly. I also tested a near-miss on purpose — "Welcome to our website" vs "Welcome to the website" — different possessive meaning, and the system correctly refused to merge them (score below threshold). The threshold wasn't picked from a formula; it came from testing real pairs and observing where the line needed to sit.

Decision 3 (the interesting one): a hypothesis that didn't survive contact with data

Later, trying to make the server light enough for a cheap hosting tier, I suspected the semantic cache — since sentence-transformers pulls in PyTorch as a dependency — was the main contributor to the server's baseline memory usage. Disabling it seemed like the obvious lever.

I made it a feature flag and measured idle RAM with it on and off, using docker stats, after eliminating every confounding variable I could find (buffered logs, wrong .env file, stale containers):

Semantic cache Idle RAM
Enabled ~378 MB
Disabled ~354 MB

A ~6% difference. The hypothesis was wrong. The real baseline cost comes from CTranslate2 and transformers overhead, not from the embedding model. I kept the feature enabled — the small memory saving didn't justify losing a caching layer I'd already proven was useful — and documented the negative result in the README instead of pretending the optimization worked.

This, more than any single feature, is the habit I'd point to as the actual "AI engineering" skill: not writing code that works, but knowing whether a specific piece of code is worth the complexity it adds — and being willing to publish the answer even when it's "no."

A glossary that survives translation — solved with fake proper nouns

Sites need certain terms to never be translated (brand names) or always translated the same way (a "Login" button that should always say "Entrar", not whatever the model feels like generating). The tricky part: what happens when that term is inside a longer sentence?

My first attempt masked terms with placeholder tokens like XVARX0, XVARX1 before sending text to the model, then restored the real values after. It worked with one placeholder in a sentence. With two, the translation collapsed into nonsense — the model seemed to lose coherence around unfamiliar alphanumeric tokens.

The fix: replace terms with fake proper nouns instead — invented but name-shaped words like Zurpaflex, Woblinka, Trencivo. Translation models are heavily biased toward preserving proper nouns untouched (that's literally part of what they're trained to do), so name-shaped tokens survive translation far more reliably than symbol-heavy placeholders — even multiple in the same sentence.

"Click Login or Sign up to use Transdom"
→ "Clique em Entrar ou Criar conta para usar o Transdom"
Enter fullscreen mode Exit fullscreen mode

Three glossary terms, all preserved and correctly substituted, in one pass.

Two things I tried and deliberately didn't ship

Automatic language detection. I tested a classification model (xlm-roberta-base-language-detection) and it performed well on full sentences (95%+ confidence) but poorly on the kind of short UI strings Transdom actually translates most — "Login", "Home" scored 50-65% confidence, and "hey" was misclassified as Swahili. Since a self-hosted deployment already knows its own site's source language, the auto-detection added a large model, latency, and a new failure mode to solve a problem that barely existed in practice. I kept the experiment in the repo as a documented, deliberate non-adoption rather than deleting the evidence.

A local LLM for "smarter" translation. I explored Ollama for handling edge cases like preserving {{templateVariables}} inside text. Then I realized: transdom.js reads the rendered DOM, after any frontend framework has already resolved {{variables}} into real values. The problem the LLM would have solved doesn't exist in this architecture. No amount of prompting fixes a problem that was never there — the fix was recognizing the premise was wrong, not building a bigger solution.

Being honest about security, too

The README has a section that says, plainly, what's covered (per-IP rate limiting, payload size limits, CORS) and what isn't (distributed abuse, request-based limits not accounting for per-request compute cost, no built-in auth, no TLS). Self-hosting means the operator inherits some of that responsibility — pretending otherwise would just mean someone finds out the hard way later.

What this project actually taught me

Building Transdom end to end — server, client library, npm package with a React hook, tests, Docker, a landing page — touched almost every layer an AI engineer actually works across day to day. But the transferable skill wasn't any single technology. It was the loop:

  1. Form a specific, falsifiable hypothesis ("quantization will be faster and smaller")
  2. Measure it for real
  3. Adopt it if the data supports it, discard it if it doesn't — and write down which one happened and why That loop is what I'd tell anyone starting the same path to practice, more than any particular library.

Repo's fully open source (MIT): github.com/hjdesigner/transdom. If you try it, or spot something worth improving, I'd genuinely like to hear about it.

Top comments (0)