In this tutorial, we will build a simple and fully functional Omegle clone using Javalin. Our application will facilitate random pairing of users for video chat, and will look a lot like the real Omegle platform (rest in peace). For the frontend, we will be using plain JavaScript and WebRTC, no frameworks or libraries. Despite this, the full JavaScript frontend will just be a bit over two hundred lines of code. The backend will be 80-120 lines, depending on whether you’re following the Kotlin or Java version of the tutorial.

Our application will look like this:


Setting up the project

You can use Maven or Gradle (or even Bazel) to set up your project. Please follow the respective guide for your build tool of choice. As for dependencies, we will just be using the Javalin bundle, which includes Javalin, Jetty, Jackson, and Logback.

Please add the following dependencies to your build file:


Project structure

We will be using the following project structure:

├── main
   ├── java/kotlin
      └── io
          └── javalin
              └── omeglin
                  ├── OmeglinMain.kt/java // main class
                  └── Matchmaker.kt/java  // matchmaking logic
└── resources
    └── public
        ├── index.html                      // html for the frontend
        ├── js
           ├── app.js                      // main class
           ├── peer-connection.js          // webrtc logic
           └── chat.js                     // chat logic
        └── style.css                       // styling

Implementing the backend

The backend will be very simple. We will need a static file handler for the frontend, a websocket handler for the WebRTC signaling. Let’s have a look at the main class:

  • Java
  • Kotlin
package io.javalin.omeglin;

import io.javalin.Javalin;
import io.javalin.http.staticfiles.Location;

public class OmeglinMain {
    public static void main(String[] args) {
        Javalin.create(config -> {
            config.staticFiles.add("src/main/resources/public", Location.EXTERNAL);
            config.router.mount(router -> {
      "/api/matchmaking", Matchmaking::websocket);

package io.javalin.omeglin

import io.javalin.Javalin
import io.javalin.http.staticfiles.Location

fun main() {
    Javalin.create {
        it.staticFiles.add("src/main/resources/public", Location.EXTERNAL)
  "/api/matchmaking", Matchmaker::websocket)

We add the static files using Loation.EXTERNAL, so that we can make changes to the frontend without restarting the server. We also add a websocket handler, which will be used for the WebRTC signaling. Finally, we start the server on port 7070. All of the backend logic will be in the matchmaking class. Before showing the full code, let’s discuss the individual classes and methods that make up the matchmaking logic.


This class handles the WebSocket connections for the application. It maintains a queue of Exchange objects, where each Exchange represents a pair of users wanting to perform an SDP (Session Description Protocol) exchange.
When the exchange is finished, the Exchange object is removed from the queue and all subsequent messages are sent directly between the users (peer-to-peer).
Video and audio streams are sent directly between the users, never through the server.

The websocket method sets up all the WebSocket event handlers:

  • onConnect: When a user connects, automatic pings are enabled to keep the connection alive.
  • onClose: When a user disconnects, pairingAbort is called to remove the user from the pairing queue (if they are in it).
  • onMessage: When a message is received, it’s processed based on its type. There are several types of messages that can be received, such as “PAIRING_START”, “PAIRING_ABORT”, “PAIRING_DONE”, and various SDP messages related to establishing the WebRTC connection. These SDP messages are simply sent to the other user in the pair, not processed by the server.


This class holds the two users (WsContext) required to perform the SDP Exchange for pairing, as well as a doneCount to track if both users have finished pairing up (for cleanup purposes). It has an otherUser method which returns the other user in the pair given one user, which is useful for passing messages between the users.


This class represents a message that can be sent over the WebSocket connection. It contains a name for the type of the message and optional data for any additional information.

The full matchmaking code

  • Java
  • Kotlin
package io.javalin.omeglin;

import io.javalin.websocket.WsContext;
import io.javalin.websocket.WsConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.ConcurrentLinkedQueue;

public class Matchmaking {
    private static final Logger logger = LoggerFactory.getLogger(Matchmaking.class);
    private static final ConcurrentLinkedQueue<Exchange> queue = new ConcurrentLinkedQueue<>();

    public static void websocket(WsConfig ws) {
        ws.onConnect(user -> user.enableAutomaticPings());
        ws.onClose(user -> pairingAbort(user));
        ws.onMessage(user -> {
  "Received message: " + user.message());
            var message = user.messageAsClass(Message.class);
            switch ( {
                case "PAIRING_START" -> pairingStart(user);
                case "PAIRING_ABORT" -> pairingAbort(user);
                case "PAIRING_DONE" -> pairingDone(user);
                case "SDP_OFFER", "SDP_ANSWER", "SDP_ICE_CANDIDATE" -> {
                    var exchange = findExchange(user);
                    if (exchange != null && exchange.a != null && exchange.b != null) {
                        send(exchange.otherUser(user), message); // forward message to other user
                    } else {
                        logger.warn("Received SDP message from unpaired user");

    private static void pairingStart(WsContext user) {
        queue.removeIf(ex -> ex.a == user || ex.b == user); // prevent double queueing
        var exchange =
                .filter(ex -> ex.b == null)
        if (exchange != null) {
            exchange.b = user;
            send(exchange.a, new Message("PARTNER_FOUND", "GO_FIRST"));
            send(exchange.b, new Message("PARTNER_FOUND"));
        } else {
            queue.add(new Exchange(user));

    private static void pairingAbort(WsContext user) {
        var exchange = findExchange(user);
        if (exchange != null) {
            send(exchange.otherUser(user), new Message("PARTNER_LEFT"));

    private static void pairingDone(WsContext user) {
        var exchange = findExchange(user);
        if (exchange != null) {
        queue.removeIf(ex -> ex.doneCount == 2);

    private static Exchange findExchange(WsContext user) {
                .filter(ex -> user.equals(ex.a) || user.equals(ex.b))

    private static void send(WsContext user, Message message) { // null safe send method
        if (user != null) {

    record Message(String name, String data) {
        public Message(String name) {
            this(name, null);

    static class Exchange {
        public WsContext a;
        public WsContext b;
        public int doneCount = 0;

        public Exchange(WsContext a) {
            this.a = a;

        public WsContext otherUser(WsContext user) {
            return user.equals(a) ? b : a;


package io.javalin.omeglin

import io.javalin.websocket.WsConfig
import io.javalin.websocket.WsContext
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentLinkedQueue

data class Message(val name: String, val data: String? = null)

data class Exchange(var a: WsContext?, var b: WsContext?, var doneCount: Int = 0) {
    fun otherUser(user: WsContext) = if (user == a) b else a

object Matchmaker {

    private val logger = LoggerFactory.getLogger(
    private val queue = ConcurrentLinkedQueue<Exchange>()

    fun websocket(ws: WsConfig) {
        ws.onConnect { user -> user.enableAutomaticPings() }
        ws.onClose { user -> pairingAbort(user) }
        ws.onMessage { user ->
  "Received message: ${user.message()}")
            val message = user.messageAsClass(
            when ( {
                "PAIRING_START" -> pairingStart(user)
                "PAIRING_ABORT" -> pairingAbort(user)
                "PAIRING_DONE" -> pairingDone(user)
                "SDP_OFFER", "SDP_ANSWER", "SDP_ICE_CANDIDATE" -> { // should only happen when two users are paired
                    val exchange = queue.find { it.a == user || it.b == user }
                    if (exchange?.a == null || exchange.b == null) {
                        logger.warn("Received SDP message from unpaired user")
                    exchange.otherUser(user)?.send(message) // forward message to other user

    private fun pairingStart(user: WsContext) {
        queue.removeAll { it.a == user || it.b == user } // prevent double queueing
        val waitingUser = queue.find { it.b == null }?.let { exchange ->
            exchange.b = user
            exchange.a?.send(Message("PARTNER_FOUND", "GO_FIRST"))
        if (waitingUser == null) {
            queue.add(Exchange(a = user, b = null))

    private fun pairingAbort(user: WsContext) {
        queue.find { it.a == user || it.b == user }?.let { ex ->

    private fun pairingDone(user: WsContext) {
        queue.find { it.a == user || it.b == user }?.let { it.doneCount++ }
        queue.removeAll { it.doneCount == 2 } // remove exchanges where both users are done


Now that we have the backend logic in place, let’s move on to the frontend.

Implementing the frontend

We’ll try to keep the frontend as simple as possible. We will use plain JavaScript and WebRTC (and some CSS), no frameworks or libraries. The frontend will be split into five files:

  • index.html: The HTML for the page (this defines the UI elements: videos, buttons, chat-log, etc)
  • style.css: The CSS for the page (this defines how everything looks).
  • app.js: The main JavaScript file (similar to the main class in the backend)
  • peer-connection.js: The WebRTC logic (sets up the peer connection and handles the SDP exchange)
  • chat.js: The chat logic (handles the chat user input and UI updates related to chat)


There is nothing special about the HTML for this project, the main elements to note are:

  • the video elements for the local and remote video streams.
  • the button elements for finding a new partner, aborting the search, and ending the call.
  • the input/output for the chat messages.

The classes on the elements are used for styling (defined in styles.css), and the IDs are used for attaching event listeners (defined in javascript files).

The app.js file is included as a module, so that we can use the JavaScript import syntax to import the other JavaScript files (native javascript modules).

<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Omeglin - Talk to strangers</title>
    <link href="/favicon.ico" type="image/svg+xml" rel="icon">
    <link rel="stylesheet" href="/styles.css">
    <img src="/omeglin.svg" alt="Omeglin logo" class="logo">
    <div class="video-panel">
        <section class="remote">
            <img src="/tail-spin.svg" class="spinner" alt="">
            <video id="remoteVideo" autoplay playsinline></video>
        <section class="local">
            <video id="localVideo" autoplay playsinline muted></video>
    <div class="chat-panel">
        <div class="chat-log" id="chatLog"></div>
        <div class="chat-controls">
            <div class="pairing">
                <button id="startPairing">Find stranger</button>
                <button id="abortPairing">Cancel</button>
                <button id="leavePairing">Disconnect</button>
            <div class="messaging">
                <input type="text" placeholder="Type a message..." id="chatInput">
                <button id="chatSend">Send</button>
<script type="module" src="/js/app.js"></script>


The app.js file is the main JavaScript file, similar to the main class in the backend. It’s responsible for initializing everything and setting up event listeners for peer-connection.js, as well as events listeners for the UI elements (except for the chat functionality, which is handled by chat.js).

import {Chat} from './chat.js';
import {PeerConnection} from "./peer-connection.js";

const peerConnection = new PeerConnection({
    onLocalMedia: stream => document.getElementById("localVideo").srcObject = stream,
    onRemoteMedia: stream => document.getElementById("remoteVideo").srcObject = stream,
    onChatMessage: message => chat.addRemoteMessage(message),
    onStateChange: state => {
        document.body.dataset.state = state;

let chat = new Chat(peerConnection);

document.getElementById("startPairing").addEventListener("click", async () => {
    peerConnection.sdpExchange.send(JSON.stringify({name: "PAIRING_START"}))

document.getElementById("abortPairing").addEventListener("click", () => {
    peerConnection.sdpExchange.send(JSON.stringify({name: "PAIRING_ABORT"}))

document.getElementById("leavePairing").addEventListener("click", () => {

window.addEventListener("beforeunload", () => {
    if (peerConnection.state === "CONNECTED") {

Thanks to hoisting, we can define the peer-connection event listeners before the chat variable is defined, even though we use the chat variable in the event listeners. This isn’t a super clean solution, but it works for this tutorial. If you want to build this application as a real project, you should probably refactor this to allow both app.js and chat.js to attach event listeners to the peer connection.


The chat.js file is responsible for handling the UI and logic for the chat messages. It’s just a few lines of code:

export class Chat {

    #input = document.getElementById("chatInput");
    #sendBtn = document.getElementById("chatSend");
    #log = document.getElementById("chatLog");

    constructor(peerConnection) {
        this.#peerConnection = peerConnection;
        this.#sendBtn.addEventListener("click", () => {
            if (this.#peerConnection.dataChannel === null) return console.log("No data channel");
            if (this.#input.value.trim() === "") return this.#input.value = "";
            this.#addToLog("local", this.#input.value);
            this.#peerConnection.dataChannel.send(JSON.stringify({chat: this.#input.value}));
            this.#input.value = "";

        this.#input.addEventListener("keyup", event => {
            if (event.key !== "Enter") return;
  ; // reuse the click handler

    updateUi(state) {
        if (["NOT_CONNECTED", "CONNECTING", "CONNECTED"].includes(state)) {
            this.#log.innerHTML = "";
        if (state === "NOT_CONNECTED") this.#addToLog("server", "Click 'Find Stranger' to connect with a random person!");
        if (state === "CONNECTING") this.#addToLog("server", "Finding a stranger for you to chat with...");
        if (state === "CONNECTED") this.#addToLog("server", "You're talking to a random person. Say hi!");
        if (state === "DISCONNECTED_LOCAL") this.#addToLog("server", "You disconnected");
        if (state === "DISCONNECTED_REMOTE") this.#addToLog("server", "Stranger disconnected");

    addRemoteMessage = (message) => this.#addToLog("remote", message)

    #addToLog(owner, message) {
        this.#log.insertAdjacentHTML("beforeend", `<div class="message ${owner}">${message}</div>`);
        this.#log.scrollTop = this.#log.scrollHeight;

Most of the code is just for updating the UI, mainly because of the different connection states that the peer connection can be in. The use of # in a JavaScript class is an access modifier, similar to private in Java/Kotlin.

The class exposes two methods, updateUi and addRemoteMessage, which are used in the callbacks for the peer connection defined in app.js. Again, this isn’t a super clean solution, so these should be attached to the peer-connection instead of being exposed if you want to build this application as a real project.


This is by far the most complex file in the project, so let’s start with a brief introduction to WebRTC and SDP.

The way a WebRTC connection is established is by exchanging SDP messages between the two users (or “peers”). These SDP messages are typically exchanged over a WebSocket connection (as is the case with our app), but you could use REST or whatever else. The way these messages are exchanged is not part of the WebRTC specification. Once the SDP exchange is complete, the media streams are sent directly between the peers (peer-to-peer).

To establish a connection, one peer must send an “offer” SDP message, and the other must send an “answer” SDP message. In our application, who sends the offer and who sends the answer is determined by the “GO_FIRST” instruction, which is sent by the backend when the two peers are paired up. Deciding who goes first is not part of the WebRTC specification, the two users just have to agree on it somehow.

The logic for the SDP exchange is different for the “offerer” (the user who goes first) and the “answerer” (the user who goes second). Let’s go through both.

The flow for the “offerer” is as follows:

  1. Establish a WebSocket connection to the backend.
  2. Receive “PARTNER_FOUND” with the “GO_FIRST” instruction.
  3. Create a peer connection, a data channel, and an offer.
  4. Set the local description of the peer connection to the offer.
  5. Send the offer SDP message to the backend.
  6. Wait for the answer SDP message from the backend and set it as the remote description.
  7. Send ICE candidates to the backend as they are generated.
  8. Receive peer ICE candidates from the backend and add them to the connection.
  9. The connection will be established based on the ICE candidates, after which video and audio streams can be sent and received. No ondatachannel event will be fired, since the data channel was created here in step 3.

The flow for the “answerer” is as follows:

  1. Establish a WebSocket connection to the backend.
  2. Receive the “PARTNER_FOUND” (without the “GO_FIRST” instruction).
  3. Wait for the offer SDP message from the backend.
  4. Create a peer connection and set the remote description to the offer SDP message.
  5. Create an answer and set the local description to the answer.
  6. Send the answer SDP message to the backend.
  7. Send ICE candidates to the backend as they are generated.
  8. Receive peer ICE candidates from the backend and add them to the connection.
  9. The connection will be established based on the ICE candidates, after which video and audio streams can be sent and received. An ondatachannel event will also be fired when the connection is established, this is the same data channel that was created in step 3 for the “offerer”.

The flows are similar, but the “offerer” must create the data channel and send the offer, while the “answerer” must wait for the offer and send the answer. Both “offerer” and “answerer” exchange ICE candidates. The data channel is obtained at different times for the “offerer” and the “answerer”, but it’s the same data channel.

Alright, now it’s finally time to look at the code:

export class PeerConnection {
    sdpExchange; // WebSocket with listeners for exchanging SDP offers and answers
    peerConnection; // RTCPeerConnection for exchanging media (with listeners for media and ICE)
    dataChannel; // RTCDataChannel for exchanging signaling and chat messages (with listeners)
    options; // constructor args {onStateChange, onLocalMedia, onRemoteMedia, onChatMessage}
    localStream; // MediaStream from local webcam and microphone

    constructor(options) {
        this.options = options;

    async init() { // needs to be separate from constructor because of async
        try {
            this.localStream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
        } catch (error) {
            alert("Failed to enable webcam and/or microphone, please reload the page and try again");
        this.peerConnection = this.createPeerConnection();
        this.sdpExchange = this.createSdpExchange();

    createSdpExchange() { // WebSocket with listeners for exchanging SDP offers and answers
        let ws = new WebSocket(`ws://${}/api/matchmaking`);
        ws.addEventListener("message", (event) => {
            const message = JSON.parse(;
            console.log("Received WebSocket message",
            if ( === "PARTNER_FOUND") this.handlePartnerFound(;
            if ( === "SDP_OFFER") this.handleSdpOffer(JSON.parse(;
            if ( === "SDP_ANSWER") this.handleSdpAnswer(JSON.parse(;
            if ( === "SDP_ICE_CANDIDATE") this.handleIceCandidate(JSON.parse(;
        ws.addEventListener("close", async () => {
            while (this.sdpExchange.readyState === WebSocket.CLOSED) {
                console.log("WebSocket closed, reconnecting in 1 second");
                await new Promise(resolve => setTimeout(resolve, 1000));
                this.sdpExchange = this.createSdpExchange();
        return ws;

    createPeerConnection() { // RTCPeerConnection for exchanging media (with listeners for media and ICE)
        let conn = new RTCPeerConnection();
        conn.ontrack = event => {
            console.log(`Received ${event.track.kind} track`);
        conn.onicecandidate = event => {
            if (event.candidate === null) { // candidate gathering complete
                console.log("ICE candidate gathering complete");
                return this.sdpExchange.send(JSON.stringify({name: "PAIRING_DONE"}));
            console.log("ICE candidate created, sending to partner");
            let candidate = JSON.stringify(event.candidate);
            this.sdpExchange.send(JSON.stringify({name: "SDP_ICE_CANDIDATE", data: candidate}))
        conn.oniceconnectionstatechange = () => {
            if (conn.iceConnectionState === "connected") {
                // ice candidates can still be added after "connected" state, so we need to log this with a delay
                setTimeout(() => console.log("WebRTC connection established"), 500);
        conn.ondatachannel = event => { // only for the "answerer" (the one who receives the SDP offer)
            console.log("Received data channel from offerer");
            this.dataChannel = this.setupDataChannel(
        return conn;

    setupDataChannel(channel) { // RTCDataChannel for exchanging signaling and chat messages
        channel.onmessage = event => {
            console.log("Received data channel message",;
            if ( === "BYE") {
                return console.log("Received BYE message, closing connection");
        return channel;

    sendBye() {
        if (this.dataChannel === null) return console.log("No data channel");

    disconnect(orignator) {
        this.dataChannel = null;
        this.peerConnection = this.createPeerConnection();

    setState(state) {
        this.state = state;

    handlePartnerFound(instructions) {
        if (instructions !== "GO_FIRST") {
            return console.log("Partner found, waiting for SDP offer ..."); // only for the "answerer" (the one who receives the SDP offer)
        console.log("Partner found, creating SDP offer and data channel");
        this.tryHandle("PARTNER_FOUND", async () => { // only for the "offerer" (the one who sends the SDP offer)
            this.dataChannel = this.setupDataChannel(this.peerConnection.createDataChannel("data-channel"));
            this.localStream.getTracks().forEach(track => this.peerConnection.addTrack(track, this.localStream));
            const offer = await this.peerConnection.createOffer();
            await this.peerConnection.setLocalDescription(offer);
            let offerJson = JSON.stringify(this.peerConnection.localDescription);
            this.sdpExchange.send(JSON.stringify({name: "SDP_OFFER", data: offerJson}))

    handleSdpOffer(offer) { // only for the "answerer" (the one who receives the SDP offer)
        this.tryHandle("SDP_OFFER", async () => {
            console.log("Received SDP offer, creating SDP answer")
            await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
            this.localStream.getTracks().forEach(track => this.peerConnection.addTrack(track, this.localStream));
            const answer = await this.peerConnection.createAnswer();
            await this.peerConnection.setLocalDescription(answer);
            let answerJson = JSON.stringify(this.peerConnection.localDescription);
            this.sdpExchange.send(JSON.stringify({name: "SDP_ANSWER", data: answerJson}))

    handleSdpAnswer(answer) { // only for the "offerer" (the one who sends the SDP offer)
        this.tryHandle("SDP_ANSWER", async () => {
            await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer));

    handleIceCandidate(iceCandidate) {
        this.tryHandle("ICE_CANDIDATE", async () => {
            await this.peerConnection.addIceCandidate(new RTCIceCandidate(iceCandidate));

    tryHandle(command, callback) {
        try {
        } catch (error) {
            console.error(`Failed to handle ${command}`, error);

This class encapsulates all of the WebRTC logic and exposes callbacks through its constructor. This hopefully makes everything easy (or at least easier?) to follow. The most difficult part is probably keeping track of the different code paths for the “offerer” and the “answerer”. To help with this, the code contains comments for which parts are called exclusively by the “offerer” or the “answerer”.


The CSS is pretty simple. It uses a CSS grid to position and size the elements, and a few CSS variables for spacing and border-radius. It also uses states set on the body to conditionally show and hide elements. For example only 1 button out of 3 is show at the time:

[data-state=NOT_CONNECTED] button#startPairing,         /* start button */
[data-state=DISCONNECTED_LOCAL] button#startPairing,    /* start button */
[data-state=DISCONNECTED_REMOTE] button#startPairing,   /* start button */
[data-state=CONNECTING] button#abortPairing,            /* abort button */
[data-state=CONNECTED] button#leavePairing {            /* leave button */
    display: block;

The full CSS is shown below:

:root {
    --spacing: 12px;
    --border-radius-large: 8px;
    --border-radius-small: 4px;

* {
    box-sizing: border-box;

body { /* wraps header and main */
    display: grid;
    grid-template-rows: auto 1fr;
    grid-row-gap: var(--spacing);
    height: 100vh;
    padding: var(--spacing);
    margin: 0;
    font-family: 'Roboto', sans-serif;

header img {
    display: block;
    margin: 4px auto;
    max-height: 40px;

main { /* wraps video-panel and chat-panel */
    display: grid;
    grid-template-columns: 4fr 7fr; /* video panel is 4/11 of the width, chat panel is 7/11 */
    grid-column-gap: var(--spacing);
    overflow: auto;

.video-panel {
    display: grid;
    grid-template-rows: 1fr 1fr;
    grid-row-gap: var(--spacing);
    overflow: auto;

    & section {
        overflow: auto;
        display: flex;
        justify-content: center;
        align-items: center;
        background: #f2f2f2;
        border-radius: var(--border-radius-large);

        & video {
            width: 100%;
            height: 100%;
            object-fit: cover;

        &.local video {
            transform: scaleX(-1);

.chat-panel {
    display: grid;
    grid-template-rows: 1fr auto;
    grid-row-gap: var(--spacing);
    padding: var(--spacing);
    background: #f2f2f2;
    border-radius: var(--border-radius-large);

    .chat-log {
        padding: var(--spacing);
        background: #fff;
        overflow-y: scroll;
        border-radius: var(--border-radius-small);
        line-height: 1.4;

        .message.local::before {
            font-weight: bold;
            color: blue;
            content: "You: ";

        .message.remote::before {
            font-weight: bold;
            color: red;
            content: "Stranger: ";

        .message.server {
            color: #999;
            font-style: italic;

    .chat-controls {
        display: flex;

        .messaging {
            display: flex;
            flex-grow: 1;

            & input {
                margin: 0 16px;
                width: 100%;
                height: 40px;
                padding: 16px;
                border: 0;
                border-radius: var(--border-radius-small);
                font-size: 16px;

                &:focus {
                    outline: none;

        .pairing button {
            width: 120px;

button {
    background: #1e88e5;
    color: #fff;
    border: 0;
    border-radius: var(--border-radius-small);
    height: 40px;
    line-height: 1;
    padding: 0 16px;
    cursor: pointer;

    &:hover {
        background: #1976d2;

/* Conditional styles for buttons */
.chat-controls .pairing button {
    display: none;

[data-state=NOT_CONNECTED] button#startPairing,
[data-state=DISCONNECTED_LOCAL] button#startPairing,
[data-state=DISCONNECTED_REMOTE] button#startPairing,
[data-state=CONNECTING] button#abortPairing,
[data-state=CONNECTED] button#leavePairing {
    display: block;

/* Conditional styles for spinner */
.spinner {
    display: none;

[data-state=CONNECTING] .spinner {
    display: block;

/* Conditional styles for remote video */
body:not([data-state=CONNECTED]) .remote video {
    display: none;

/* Conditional styles for chat */
body:not([data-state=CONNECTED]) .chat-controls .messaging {
    filter: grayscale(1);
    opacity: 0.6;
    pointer-events: none;

/* Mobile layout */
@media (max-width: 768px) {
    main { /* put video panel on top of chat panel */
        grid-template-columns: none;
        grid-template-rows: 1fr 2fr;
        grid-row-gap: var(--spacing);

    .video-panel { /* put video side by side */
        grid-template-rows: none;
        grid-template-columns: 1fr 1fr;
        grid-column-gap: var(--spacing);


That’s it! We’ve built a simple Omegle clone using Javalin, WebRTC, and plain JavaScript. The full code is available on GitHub (link below), configured using Maven. If you have been following the tutorial and copy-pasting, you are missing a favicon and an SVG spinner, both of which you can also find on GitHub.

If you have any questions or comments, please reach out to us on Discord or GitHub.