The original Omegle proved something for fourteen years: two strangers who would never have met can have a good conversation. It shut down in 2023. What it never solved is the part I kept running into myself — most of the world still can't understand each other. You get matched with someone in São Paulo or Jakarta, you both say "hi", and that's where it ends. So I built Veilr: a random chat — text, voice or video — where your messages can arrive in the other person's language, and on a call you read live captions of what they say, translated into yours. It runs in the browser in 16 interface languages and on Android. I'm the only developer. This is the engineering story: the parts I'd tell a friend over coffee, including the ones that broke. Three formats, one idea Text, voice and video are different transports, but the product rule is the same: the other person should be able to understand you without either of you doing anything. Text. When the language you've set the app to differs from your partner's, your message is translated before delivery (Gemini does the translation). The original is always one tap away, because a translation you can't check is a translation you can't trust. The allowance belongs to the person reading the translation, and when it runs out the message is delivered exactly as typed — nothing is silently dropped. Voice and video. A streaming speech-to-text pipeline (Soniox) produces captions translated into the listener's language, rendered over the call surface. It works in a voice-only call too, where there's no camera at all. You hear their voice, you read their words. The interesting design constraint was who pays for what. Captions are switched on by the side that reads them. That means your microphone audio may be streamed to the speech provider because your partner turned captions on — so the app tells you that. That caption audio goes from the speaker's browser straight to the provider — our backend only mints a short-lived session token. Call media is a different path: where a direct one isn't available it goes through our relay (more on that below). Caption text is relayed live through our WebSocket and not stored. The architecture in one picture Three rules that everything else hangs off: The transport is the only door to clients. Services never touch a WebSocket. They call transport.send(clientId, msg) or transport.publish('session:' + chatId, msg) . The implementation is ws plus a Redis pub/sub relay, so two people who landed on different pods still get each other's messages and signaling. Swapping the transport is a one-module job. Redis for the ephemeral, Postgres for identity. Queues, presence, chat rooms, auth tokens — Redis, with TTLs. Users, purchases, contacts, saved private messages — Postgres. Money is a stored procedure. Every paid grant goes through a PostgreSQL function keyed by the payment id — one for Boost time, one for caption minutes — so a webhook delivered twice can't grant twice. That's it. No microservices, no queue brokers beyond Redis, no framework magic. Node on the server, React on the client, Capacitor for the Android build. Matching: preferences, not filters Early on I had "filters" — gender, age, country — and a queue that only matched exact hits. With a small user base that's a machine for empty queues: you set "women, 25–30, Spain" and wait forever. Now, for a free account, every filter is a preference with a timer: Your gender preference is held for about a second and a half — and skipped outright when nobody of that gender is online in your format right now, because waiting for someone who isn't there is just waiting. Age and country relax after five seconds. Shared interests are an additive score that ranks candidates toward you; they never block a match. Only your preference relaxes. The other side's is always respected — if someone wants to meet only women, they never get matched with a man because my timer ran out. Boost turns the same preferences into guarantees, plus first pick from the queue. It's the main paid thing, and it isn't a different product: the same matcher with the timers set to "never". (Caption minutes can be topped up separately.) The claim itself — "these two people are now a pair" — is one atomic Lua script in Redis. Two pods can race on the same partner, and exactly one wins; the loser goes back to the queue with no trace. I tried doing this in JavaScript first (pop, check, pop again). Don't. WebRTC: what the textbooks skip Perfect negotiation is not optional. Both sides can decide to renegotiate at the same time (glare). The pattern — one polite peer that rolls back its own offer, ICE restart on failure, keeping the m-line order stable across renegotiations — is the difference between "works in the demo" and "works when someone toggles their camera twice". The day Google's STUN went quiet. For weeks my phone-to-phone tests connected fine — from my desk. Then all five of Google's public STUN servers went silent from both of my phones on a Russian mobile network — and I nearly missed it, because my desktop sat behind a VPN and kept working. When I finally measured a pair the way users actually have it — one phone behind a cone NAT, the other behind a symmetric NAT, no VPN — the result was 0 connections out of 7 without a relay, and a connection in 3 seconds with one. I'd been treating the relay as a fallback. For that pair type it isn't a fallback; it's the connection. So Veilr now runs a self-hosted coturn plus Cloudflare's TURN as relay, and credentials are minted per session with an HMAC — the server never stores TURN passwords. I don't have a population-wide share to quote ("X% of calls relay"), and I won't invent one. What I have is the measurement above, and no more "works on my machine". Devices switch without renegotiation. Changing camera or microphone is getUserMedia({deviceId}) → replaceTrack . No new offer, no glare, no flicker. Captions: the pipeline The browser asks our server for permission to caption this call; the server checks the reader's balance and answers with a short-lived provider token. From then on the audio goes from the speaker's browser to the speech provider, the recognised text comes back to the reader, and the reader's client sends the caption line through our WebSocket to be drawn over the call. Metering runs on the reader's side in 30-second ticks: five free minutes per account, then a Boost pass or a minute pack. Nothing about this needs the server to hear anyone — which is the point. One WebSocket layer, many pods Two people in one chat can be connected to different server pods. Every message and every signaling frame goes through the transport, and the transport relays across pods with Redis pub/sub: a pod publishes to server_messages:{otherPodId} , membership of "who is in which chat" lives in Redis, not in process memory. That's what lets me roll out a new server version without dropping the calls in progress on the old one. Privacy rules that are code, not policy The sentence in the privacy policy that I care about most is one I could only write after making the code enforce it: a random chat's text is not written to our permanent database . Messages are relayed between the two clients through the transport; a short buffer of the last 50 messages lives in Redis and expires within 15 minutes of the chat ending. There is no table for them, so a subpoena finds nothing older than that buffer. What does leave our servers is named in the privacy policy: chat text may be screened by Google's Perspective, it goes to Gemini when translation is on, and if you report a chat, that text is analysed so the report isn't judged blind. The one exception is deliberate: if both people tap "add to contacts", that conversation moves into their private chat history — and stays translated, with the original next to each message. That's how a stranger becomes the person you practise Spanish with on Tuesdays. Private messages are encrypted in transit and stored so you can come back to them; it is not end-to-end encryption, and I don't call it that. Anonymity is a default, not a claim about the future: the stranger you're matched with never sees your name, email or sign-in account — until you both tap Connect, which is exactly the moment you agree to exchange them. Sign-in itself is required — a social account you already have. It's the abuse control a one-person project can afford; there are no passwords and no email forms to protect. What we do about minors — and what we don't We ask for a date of birth at sign-up and the server refuses a match to any account recorded under 18. We don't estimate anyone's age from their face and don't run a document check. Anyone can report in one tap (and block from the same dialog); a report about a suspected minor skips automated analysis entirely and goes to a person. I'd rather state the limits than promise a wall that isn't there. Sixteen languages, one dictionary guard The interface ships in 16 languages, Arabic included. What bit me wasn't translation but drift : two locales had nine more keys than the other fourteen, so users of Spanish or Turkish saw English strings in the middle of a translated screen — and no test noticed, because every locale loaded fine. Now a test asserts that every locale has exactly the key set of the English one, placeholders included. Boring tests catch boring bugs, and those are most bugs. What I'd tell myself a year ago Liquidity beats features. Nothing I shipped mattered as much as whether someone was in the queue when you pressed Start. Everything about the launch is built around that: a time window, communities arriving together. Write the privacy promise you can enforce with a schema. If there's no table, the promise is cheap to keep — and the policy stops being a wish list. What's next Group rooms for language practice, so three or four people can share one set of captions; pushing the caption delay down for the pairs where it lags; and, if the launch brings them, the communities themselves — a scheduled window per language pair beats any algorithm I can write. Honest limits It's a one-person project with a small user base, so outside a launch window you may wait; the window I announce and sit in myself is 16:00–20:00 UTC. Signing in with a social account is required (the providers you get are the ones on the sign-in screen). Captions are metered: five free minutes per account and a run of message translations, then a Boost pass or a minute pack. Veilr is not available in the United Kingdom right now. And translation is machine translation — good enough to keep a conversation going, not a substitute for the person you'll eventually meet without it. Numbers, because you'll ask One developer. 16 interface languages, 200+ public pages. The first paying customer arrived in September 2026. The user base is still small enough that I read every report myself. I'll post the launch-day numbers in a follow-up. Try it: veilr.me in the browser, or the Android app — sign in, pick text, voice or video, and switch captions on. If you try it, I'd love two things back: which language pair you used, and whether the captions kept up. — Vasiliy, veilr.me