🎉 New! Integrate Beams with Chatkit for powerful push notifications—find out more

Extensible API for in-app chat


Build scalable realtime features


Programmatic push notifications



Read the docs to learn how to use our products


Explore our tutorials to build apps with Pusher products


Reach out to our support team for help and advice

Sign in
Sign up

Building a video call and chat app with Electron - Part 1: Adding WebRTC video streams

  • Wern Ancheta

February 25th, 2019
This tutorial uses Node, React and Electron.

In this series, we’re going to look at how you can build a video call and chat app with Simple Peer, Pusher Channels, and Chatkit.

WebRTC is the web’s free and open-source solution for implementing realtime communication between users. The only problem is its complicated to set up, especially if you don’t have knowledge of how it’s supposed to be implemented. There are also differences in browser support and implementation of the Web APIs surrounding it (for example, navigator.getUserMedia) which complicates it even more.

Thankfully, there are libraries such as Simple Peer which make the implementation of WebRTC a bit easier.


Basic knowledge of React is required.

You also need a Channels and Chatkit app instance. For the Channels app, you need to enable client events under the settings page.

The following versions are used on this tutorial, install these (or higher versions) if you don’t already have them:

  • Node 11.2.0
  • Yarn 1.13.0
  • React 16.7.0
  • Electron 4.0.2

If you encounter any problems getting the app to work, try using the versions above. For everything else, please refer to the project’s package.json file.

Lastly, you also need ngrok to expose the server.

App overview

The app will have two screens: login and group video call/chat. The first thing that the user will see when they open the app is the login screen. This is where a random channel name is generated for them by default. The user can either use it (if they’re the initiator) or enter an existing channel name (if they want to join that channel).

When they log in, the app makes a request to the server which keeps track of the channels and the users inside it. The server is also responsible for authenticating the user so that they can readily use channels on the client-side without having to go through the server every time an event is emitted.

Once logged in, the app will ask for the user’s video stream. And anytime a new user logs in to that same channel, a peer connection is established so they could share their video stream to each other.

Here’s what the app will look like:

You can view the code for this whole series on its GitHub repo. Each part of the series is in its own branch (part1, part2, and part3). There’s also a starter code so you don’t have to deal with bootstrapping and setting up the app from scratch. The starter code is in the starter branch. Any post-tutorial updates (if any) will be on the master branch.

Building the app

I’ve used create-react-app to bootstrap the app like so:

    npx create-react-app electron-videochat

But to ensure that we’re both using the same package versions, just clone the repo, switch to the starter branch and execute yarn inside the generated directory. That will install the dependencies and perform any initial setup necessary:

    git clone https://github.com/anchetaWern/ElectronVideoChat.git
    cd ElectronVideoChat
    git checkout starter

Once all the dependencies are installed, open the .env file at the root of the project directory and add your Pusher credentials:


Note that the REACT_APP_ prefix is required since we’re within an app created with create-react-app.

Login screen

Now we’re ready to add the code for the login screen. Start by importing the packages we need and adding your Pusher credentials:

    // src/screens/Login.js
    import React, { Component } from "react";
    import { Container, Row, Col, Form, Button } from 'react-bootstrap';
    import axios from 'axios';
    import Pusher from "pusher-js";

    import uniquename from '../helpers/uniquename';
    import stringHash from "string-hash";

    const channel_name = uniquename();

    const PUSHER_APP_KEY = process.env.REACT_APP_PUSHER_APP_KEY;
    const BASE_URL = "YOUR NGROK HTTPS URL"; // replace this later

Next, initialize the state and the instance variables:

    class LoginScreen extends Component {

      state = {
        username: '',
        channel: channel_name,
        isLoading: false

      constructor(props) {
        this.pusher = null; // for storing the Pusher instance
        this.my_channel = null; // for storing the current user's channel

      // next: add render code

    export default LoginScreen;

Render the login screen’s UI:

    render() {
      return (
          <Row className="justify-content-md-center">
            <Col md="4"></Col>
            <Col md={4}>
                  <Form.Control type="text" placeholder="username" value={this.state.username} onChange={this.onTypeText} />

                  <Form.Control type="text" placeholder="channel" value={this.state.channel} onChange={this.onTypeText} />
                  <Form.Text className="text-muted">
                    This is the name of your channel. Replace this if you want to connect to an existing channel.

                <Button variant="primary" type="button" onClick={this.login} disabled={this.state.isLoading}>
                  {this.state.isLoading ? 'Logging in…' : 'Login'}

            <Col md="4"></Col>

    // next: add function for updating text fields

Here’s the function for updating the values for the text fields:

    onTypeText = (evt) => {
      const field = evt.target.getAttribute('placeholder');
        [field]: evt.target.value

Inside the login function, we let the user subscribe to their own channel. This channel will be used by other users to communicate with the current user. By initializing a new Pusher instance, we send a request to the /pusher/auth endpoint of the server (we’ll create this one later). For now, know that we’re using it to authenticate the user with Pusher so they could emit and receive events directly from the client side:

    login = async () => {
      const { username, channel } = this.state;
      const user_id = stringHash(username).toString();

        isLoading: true

      this.pusher = new Pusher(PUSHER_APP_KEY, {
        authEndpoint: `${BASE_URL}/pusher/auth`,
        cluster: PUSHER_APP_CLUSTER,
        encrypted: true

      this.my_channel = this.pusher.subscribe(`private-user-${username}`); 
      this.my_channel.bind("pusher:subscription_error", (status) => {
        console.log("error subscribing to channel: ", status);

      // next: add code when subscription succeeded

Once the subscription succeeds, we make another request to the server to register that this user has logged in:

    this.my_channel.bind("pusher:subscription_succeeded", async () => {
      console.log("subscription to own channel succeeded");
      try {
        const response = await axios.post(`${BASE_URL}/login`, {

        if (response.statusText === 'OK') {
            isLoading: false,
            username: '',
            channel: ''

          // navigate to the group chat page with all the relevant data
          this.props.navigation.navigate('GroupChat', {
            pusher: this.pusher,
            my_channel: this.my_channel

      } catch (e) {
        console.log("error occured logging in: ", e);

In the login screen, we used a helper called uniquename. It’s used for generating random channel names. Go ahead and create it:

    // src/helpers/uniquename.js
    var generateName = require("sillyname");

    const generateUsername = () => {
      const min = 10;
      const max = 99;
      const number = Math.floor(Math.random() * (max - min + 1)) + min;
      const username = generateName().replace(" ", "_") + number;
      return username;

    export default generateUsername;

Group chat screen

Let’s now proceed with the group chat screen. In this part of the series, we’re just going to implement the video call.

Start by importing the things we need:

    // src/screens/GroupChat.js
    import React, { Component } from "react";
    import { Container, Row, Col, Form, Button } from "react-bootstrap";
    import { Player, ControlBar } from "video-react";

    import Peer from "simple-peer"; // for implementing peer-to-peer communication
    import axios from "axios";
    import Masonry from "react-masonry-component"; // for rendering video streams in a nice way

    import ab2str from "../helpers/arrayBufferToString";


Next, add the default values for the state and instance variables that we’re going to use:

    class GroupChatScreen extends Component {
      state = {
        is_initialized: false, // controls whether to display the group chat UI or not
        streams: [], // array of video streams to display
        username: "" // username of the user to send the message

      constructor(props) {
        this.users = []; // the array of users to current user is attempting to connect to
        this.user_channels = []; // the Pusher channels the current user is connected to (except their own) 
        this.peers = []; // the peers the current user is connected to
        this.is_initiator = false; // whether the current user is considered the initiator in the last peer connection
        this.peer_username = null; // the username of the last user the current user connected to
        this.has_own_stream = false; // if the current user has already added their own stream to this.state.streams

      // next: add componentDidMount()

Once the component is mounted, we make a request to the server to get the array of usernames of users who are currently inside the room. Once we have that, we initialize a new peer connection with the first user in the array. Note that this won’t even be called if the user who entered is the very first user:

    async componentDidMount() {
      const { navigation } = this.props;
      this.username = navigation.getParam("username");
      this.channel = navigation.getParam("channel");
      this.pusher = navigation.getParam("pusher");
      this.my_channel = navigation.getParam("my_channel");

      try {
        const response_data = await axios.post(`${BASE_URL}/users`, {
          channel: this.channel,
          username: this.username

        this.users = response_data.data.users;
        if (this.users.length) {
      } catch (err) {
        console.log("error getting users: ", err);

      // next: listen for client-initiate-signaling event

    // last: add _initializePeerConnection function

You might be wondering why we only initialize the peer connection to only the first user in the array. That’s because we can’t do that inside a loop since it will execute very fast. Which means that the peer connection might not be successfully established because the client has to throw signals to more than one user. In other words, there’s going to be chaos since we don’t know which signal came from which user. So it’s better to establish a peer connection one at a time.

Next, we listen for the client-initiate-signaling event. This is fired when a new user joins the room. Once this is fired, we create a new peer instance and subscribe to that user’s channel. The user who receives this event is considered as the initiator. This means that their peer data immediately becomes available through peer.on("signal") once they create a new peer instance (new Peer()). To make sure the peer data (this.signal) is already available, we add a five-second delay before triggering the client-peer-data event:

    // (3) user A receives event (client-initiate-signaling) from user B and setups peer connection
    this.my_channel.bind("client-initiate-signaling", data => {

      this.is_initiator = true; // whoever receives the client-initiate-signaling is the initiator
      this._createPeer(data.username); // create new peer instance

      this.initiator_channel = this.pusher.subscribe(

      this.initiator_channel.bind("pusher:subscription_error", () => {
        console.log(`error subscribing to signaling user ${data.username}`);

      this.initiator_channel.bind("pusher:subscription_succeeded", () => {
        setTimeout(() => {
          if (this.signal) {
            // (5) user A triggers event (client-peer-data) containing their signal to user B
            this.initiator_channel.trigger("client-peer-data", {
              username: this.username,
              peer_data: this.signal
          } else {
            console.log("There's no signal");
        }, 5000);

    // next: add client-peer-data listener

The client-peer-data event is a way for the two users to send their peer data to each other. It contains the username and the peer_data of the user who sent it. When it is fired, we call Simple Peer’s peer.signal method and pass in the peer_data. We’re using JSON.parse because it is stringified before storing to this.signal:

    // (6) user B receives the event (client-peer-data)
    this.my_channel.bind("client-peer-data", data => {
      const user = this.peers.find(item => {
        return item.username === data.username;
      if (user && data) {
        // (7) user B throws back the signal to user A via peer signaling (peer.signal)
        // OR
        // (10) user A receives the event (client-peer-data) and throws back the signal to user B via peer signaling (peer.signal)
        console.log("now sending data via peer signaling: ", data);
      } else {
        console.log("cant find user / no data");

      is_initialized: true

Inside the initializePeerConnection method, we subscribe to the user’s channel. Once the subscription is successful, we create the peer connection (this._createPeer). After that, we wait for five seconds before we inform the remote peer that we’re trying to initiate a peer connection. Again, this delay is to ensure that this user’s peer connection is already ready to receive signals from the remote peer:

    _initializePeerConnection = username => {
      const channel = this.pusher.subscribe(`private-user-${username}`);

      channel.bind("pusher:subscription_error", status => {
        console.log("error subscribing to peer channel: ", status);

      channel.bind("pusher:subscription_succeeded", () => {
        // (1) user B setups peer connection (non initiator)
        this.is_initiator = false;
        this._createPeer(username); // this is always the non-initiator
        this.peer_username = username; // so we know who's the last user we're creating the peer connection for

        // (2) user B triggers event (client-initiate-signaling) to user A
        setTimeout(() => {
          // inform the remote peer that we’re trying to initiate a peer connection
          channel.trigger("client-initiate-signaling", {
            username: this.username
        }, 5000);

    // next: add _createPeer function

To make it easier to understand, we’ll refer to the user who called the method above (_initializePeerConnection) as “User B”. While the recipient of the client-initiate-signaling event as “User A”. User A is always considered the initiator. In a peer-to-peer connection, the initiator is the user who receives their peer data right away via the peer.on("signal") event. This is because initiator: true is set when their peer connection was created. For non-initiators, the peer.on("signal") event is only fired once the initiator executes the peer.signal method on their side.

Let’s take a look at what the _createPeer method does. It will request for the user’s video stream via the navigator.getUserMedia method. This returns a Stream object which we convert to a URL so we can pass it to the video-react component for streaming. Once the stream is returned, we create the user’s peer connection via the _connectToPeer method:

    _createPeer = username => {
        { video: true, audio: true },
        stream => {
          const video_stream = window.URL.createObjectURL(stream);

          if (!this.has_own_stream) { // if the user hasn't already added their own video stream
            this.setState(prevState => ({
              streams: [...prevState.streams, video_stream]
            this.has_own_stream = true;

          console.log(`${this.username} is connecting to remote peer...`);
          this._connectToPeer(username, stream);
        err => {
          console.log("error occured getting media: ", err);

Here’s the _connectToPeer method. This creates a new peer connection. This serves as the channel for video/audio streaming and binary/text data to be sent to and from this user. Note that you have to create a separate peer connection for each user you want to connect to. That’s why we’re storing them in the this.peers array so we can find the exact peer to send a message to later on. That’s also the reason why we need to trigger the client-initiate-signaling event for every user who is in the room when someone new comes in. This is the responsibility of the new user (User B). But even though functionally, User B is considered the “initiator” since they’re the one who “high fives” each user in the room. It’s actually the user (User A) who receives the message that’s considered the initiator since they’re the ones who sends their peer data first (via the client-peer-data event):

    _connectToPeer = (username, stream = false) => {
      const peer_options = {
        initiator: this.is_initiator,
        trickle: false

      if (stream) {
        peer_options.stream = stream;

      const p = new Peer(peer_options);

        username: username,
        peer: p

      p.on("error", err => {
        console.log("peer error: ", err);

      // next: add p.on("signal")


As mentioned earlier, p.on("signal") is fired immediately after creating a new peer connection via new Peer() if you passed in initiator: true for the peer options. We temporarily store the peer data (this.signal) to be used later once we’re ready to send it via the client-peer-data event. The other time this event is fired is when User B receives a signal from User A. This happens further down the line (it’s the 9th step as you can see from the comments). Once that happens, we find the remote peer’s channel and trigger the client-peer-data event:

    p.on("signal", data => {
      if (this.is_initiator && data) { // initiator (User A) receives their peer data
        // (4) user A receives signal
        this.signal = JSON.stringify(data);
      } else { // non-initiator (User B) receives peer data from User A 
        // (8) user B generates an answer
        const peer = this.user_channels.find(ch => {
          return ch.username === this.peer_username;
        if (peer) {
          // (9) user B triggers event (client-peer-data) containing the answer to user A
          peer.channel.trigger("client-peer-data", {
            username: this.username,
            peer_data: JSON.stringify(data)

    // next: add p.on("connect")

p.on("connect") is the last event that’s fired which tells us that both peers are now connected. This is also where we repeat the process of initializing the peer connection for the next user (if any):

    p.on("connect", () => {
      console.log(`(10) ${this.username} is connected`);

      this.users.shift(); // remove the first user in the users array since we're already done connecting to them

      if (this.users.length) {
    // next: add p.on("stream")

Once the remote peer’s video stream becomes available, p.on("stream") is fired. We simply push it to the video streams we already have so it’s rendered in the UI as well:

    p.on("stream", stream => {
      console.log(`${this.username} received stream`);
      const peer_video_stream = window.URL.createObjectURL(stream);

      this.setState(prevState => ({
        streams: [...prevState.streams, peer_video_stream]
    // next: add p.on("data")

Lastly, p.on("data") is fired everytime the user receives a message from a remote peer. If any of the remote peers executes the peer.send method, this event is fired off:

    p.on("data", data => {

Next, we can now render the UI. This contains the form for sending a message to a specific user in the room and the video streams for all the users in the room (including the current user’s stream):

    render() {
      return (
        <Container fluid={true}>
          <Row className="Header justify-content-md-center">
            <Col md="4">

          {!this.state.is_initialized && <div className="loader">Loading...</div>}


              <Button variant="primary" type="button" onClick={this._sendMessage}>
                Send Message

          {this.state.is_initialized && (
              <Col md={8} className="VideoContainer">

    // next: add _renderStreams function

Here’s the function for rendering the video streams:

    _renderStreams = () => {
      return this.state.streams.map((video) => {
        return (
          <div className="VideoBox">
            <Player autoPlay={true} src={video}>
              <ControlBar autoHide={false} disableDefaultControls />

    // next: add onTypeText and _sendMessage function

Optionally, you can add the following methods as well. These ones, including the peer.on("data") and the ab2str function from earlier are not really part of the video call functionality. We’re only using it to test if the peer connection between each user is actually established. onTypeText is used for updating the username of the user to send a message to, while _sendMessage actually sends the message. This fires off peer.on("data") on the remote peer’s side:

    onTypeText = evt => {
        username: evt.target.value

    _sendMessage = () => {
      const user = this.peers.find(item => {
        return item.username === this.state.username;
      if (user) {
        user.peer.send(`you received a message from ${this.username}`);

Here’s the code for the ab2str function:

    // src/helpers/arrayBufferToString.js
    const arrayBufferToString = buf => {
      return String.fromCharCode.apply(null, new Uint16Array(buf));

    export default arrayBufferToString;

Since the signaling process can get confusing, here’s a summary of what we just did. Again, User A is considered the initiator, and the one who receives client-initiate-signaling event. While User B is the new user who joins the room and is the one who triggers the client-initiate-signaling event. The process starts when a new user joins the room and ends when the new user has connected to all the users in the room:

  1. User B setups a new peer connection (initiator: false).
  2. User B triggers event (client-initiate-signaling) to User A.
  3. User A receives event (client-initiate-signaling) from User B and setups a new peer connection (initiator: true).
  4. User A receives a signal (peer data).
  5. User A triggers event (client-peer-data) containing their signal to User B.
  6. User B receives the event (client-peer-data) from User A.
  7. User B throws back the signal to User A via peer signaling (peer.signal).
  8. User B generates an answer (peer.on("signal") is fired off).
  9. User B triggers event (client-peer-data) containing the answer to User A.
  10. User A receives the event (client-peer-data) and throws back the signal to User B via peer signaling (peer.signal)

Once step #10 is completed, peer.on("connect") is fired off for both users.

Server component

Now we’re ready to work on the server. Navigate inside the server directory and install the dependencies:


Next, update server/.env file with your Pusher Channel credentials:


Next, add the server code. Start by importing the packages and initializing the server, as well as Pusher:

    // server/server.js
    var express = require("express");
    var bodyParser = require("body-parser");
    var Pusher = require("pusher");
    const cors = require("cors");


    var channels = [];

    var app = express();
    app.use(bodyParser.urlencoded({ extended: false }));

    var pusher = new Pusher({
      appId: process.env.APP_ID,
      key: process.env.APP_KEY,
      secret: process.env.APP_SECRET,
      cluster: process.env.APP_CLUSTER

When the user clicks on the Login button, a request is made to the /pusher/auth and /login endpoints. /pusher/auth is what we use to authenticate the user so they can emit events on the client side:

    app.post("/pusher/auth", (req, res) => {
      const socketId = req.body.socket_id;
      const channel = req.body.channel_name;
      console.log('logging in...');
      var auth = pusher.authenticate(socketId, channel);
      return res.send(auth);

On the other hand, the /login endpoint is the one that’s responsible for updating the channels array to add the username of the user who is logging in, and the channel they belong to:

    app.post('/login', async (req, res) => {
      const { channel, username } = req.body;

      var channel_index = channels.findIndex((c) => c.name == channel);
      if (channel_index == -1) {
        console.log("channel not yet created, so creating one now...");

          name: channel,
          users: [username]

        return res.send('ok');

      } else {
        if (channels[channel_index].users.indexOf(username) == -1) {
          console.log("channel created, so pushing user...");

          return res.send('ok');

      return res.status(500).send('invalid user');

Lastly, the /users endpoint is what the app is using to determine which users belong to the same channel as the user who is currently logging in:

    app.post('/users', (req, res) => {
      const { channel, username } = req.body;
      const channel_data = channels.find((ch) => {
        return ch.name == channel;

      let channel_users = [];
      if (channel_data) {
        channel_users = channel_data.users.filter((user) => {
          return user != username;

      return res.json({
        users: channel_users

    var port = process.env.PORT || 5000;

Running the app

You’re now ready to run the app. Start by running the server and exposing it with ngrok:

    node server/server.js
    ./ngrok http 5000

Next, add the following scripts to the app’s package.json file:

    "scripts": {
      "electron": "electron .",
      "electron-dev": "ELECTRON_START_URL=http://localhost:3000 electron ." // doesn't work on Windows

If you’re on Windows, replace the electron-dev script with the following:

    "electron-dev": "set ELECTRON_START_URL=http://localhost:3000 && electron ."

After that, update the Login and VideoChat screens with the ngrok HTTPS URL and start the app:

    yarn start
    yarn run electron-dev

At this point, you should now be able to log in to the app and test it out. Be sure to use the same channel name for each instance. You can run yarn run electron-dev on multiple terminal windows to test out group calls.

Note that if you’re on a desktop and using a webcam, it might not be capable of handling two video streams simultaneously. I suggest you test it out with this web app first. Open it in two separate browsers and check to see if you get an output like this:

The smaller picture on the lower right is the other user’s stream. If you’re only getting one stream on both browsers then your hardware can’t handle two streams simultaneously. In this case, you can either update the code so that only the initiator shares their stream. Or you can get another computer or test it with a friend.


That’s it! In this tutorial, you’ve learned how to create a video call app using React and Electron. Specifically, you learned how to use the Simple Peer library to easily implement WebRTC video streams. We also used Pusher Channels as a signaling server, which made it easier to communicate peer data between the peers.

Stay tuned for the second part where we add the chat and file-sharing feature with Chatkit.

You can view the code for this whole series on its GitHub repo.

Clone the project repository
  • JavaScript
  • Node.js
  • React
  • Chat
  • Chatkit
  • Channels


  • Channels
  • Chatkit
  • Beams

© 2020 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.