About Experience Projects Skills Blog Contact Resume

Getting Started with OBS WebSocket in an Electron App

How K3lPoke OBS Tools talks to OBS Studio from an Electron app, and what I'd change if I started over.

All posts

K3lPoke OBS Tools is a desktop app I built for Pokémon streamers. It plays Twitch clips, runs chat commands and moves scenes around, and all of that means controlling OBS from outside OBS. I expected that part to be hard. It wasn't. OBS has shipped with a WebSocket server since version 28, and the protocol docs are good. The time went into learning the event model and deciding where the connection should live inside Electron.

What OBS WebSocket is

When OBS is running, it can start a local WebSocket server. Your app connects to it like any other WebSocket client, sends JSON requests, and gets responses and events back. You don't have to speak the protocol by hand: obs-websocket-js is the official JavaScript client and handles authentication for you.

There are two kinds of messages. Requests ask OBS to do something and return a result, like switching scenes. Events are pushed to you when something changes, like a scene switch, a source being hidden, or a recording starting or stopping.

Connecting

The connection lives in the Electron main process:

import OBSWebSocket from 'obs-websocket-js';

const obs = new OBSWebSocket();

async function connectToOBS(host = 'localhost', port = 4455, password = '') {
  await obs.connect(`ws://${host}:${port}`, password);
  console.log('Connected to OBS');
}

obs.on('ConnectionClosed', () => {
  // retry logic here
});

This tripped me up early. I first tried opening the socket from the renderer and ran into context isolation problems. Moving it to the main process fixed that, and the renderer now reaches OBS through IPC.

Talking to it from the UI

Electron passes messages between the main process and the UI with ipcMain and ipcRenderer. Each OBS action gets one handler in main, one line in the preload script, and a plain function call in the UI:

// main process
ipcMain.handle('obs:switchScene', async (_, sceneName) => {
  await obs.call('SetCurrentProgramScene', { sceneName });
});

// preload
contextBridge.exposeInMainWorld('api', {
  obs: { switchScene: (name) => ipcRenderer.invoke('obs:switchScene', name) },
});

// renderer
await window.api.obs.switchScene('Gameplay');

The preload script decides what the UI is allowed to do. The renderer can switch scenes, but it never touches the socket.

Reacting to events

You don't need to keep asking OBS what state it's in. You subscribe to events and react when they arrive. The clip queue listens for MediaInputPlaybackEnded and plays the next clip when the current one finishes:

obs.on('MediaInputPlaybackEnded', ({ inputName }) => {
  if (inputName === CLIP_SOURCE_NAME) {
    playNextInQueue();
  }
});

One warning: some events don't behave exactly as the docs describe, and it depends on the OBS version. Test against the version your users run before you build logic around an edge case.

What I'd do differently

Reconnection. My first version used a simple retry loop, and it didn't account for OBS being closed and reopened, which starts a new WebSocket session. Exponential backoff that checks the session on reconnect would have saved me a lot of debugging later.

I'd also wrap the OBS calls sooner. I had obs.call() scattered across the codebase, so when the protocol changed I had to touch a lot of files. Next time I'd start with one small service module that every OBS call goes through.

Links