Writing a JavaScript/PHP Chat Server

Disclaimer

This tutorial was written in 2003, which is truly prehistoric in internet terms. If you're looking to write a modern chat server that works in all browsers, here are a couple considerations:

  • Don't write your own, just adapt an existing one. There are plenty of good free (and bad) ones out there. A quick google search reveals lots of things like this.

  • If you are intent on writing your own, don't follow this guide (unless you are a time traveler from 10 years ago, or a masochist). There are nicer ways of implementing the same functionality...

    Front-End

    Iframe polling is silly in this day and age, you should use XHR instead (if you're using jQuery, that's the reliable old $.ajax). This has been available in every browser for years.

    If you want to be bleeding edge, try using Web Sockets instead of AJAX polling, though that will require server support...

    Back-End

    Apache+PHP+MySQL is not the best architecture for a realtime chat application. If I were writing one today, I'd probably use web sockets with an event-based back-end (e.g. eventmachine, node.js, etc.). I'd might just forgo the DB and keep stuff in memory, though if you had to scale across multiple app servers, etc., Redis might come in handy.

If you still really want to go the LAMP+iframe route, that's cool and all, but note that I probably won't respond to questions about it or be able to offer you any debugging tips.

—Jon
2012-06-12

created 2003-12-19 by jon
last updated 2006-08-16 by jon

Introduction

Internet Chat is one of the most efficient forms of long-distance communication. It makes it possible to interact with people across the globe in real-time at virtually no cost to the end-user. Whether it's used to check up on the folks, get answers from a customer service rep, or talk to potential clients, Internet Chat is an invaluable tool for commercial and home users alike.

In this article we will build a basic chat application, jenChat, which can be customized for your needs. In a general sense, we will learn how to implement remote scripting with just a little DHTML.

Creating a chat application from scratch may seem like a daunting task, but it's really quite simple when you think about it. On the most basic level, all a chat server really does is take messages submitted by a participant and push them out to everyone else. Everything else from emoticons to buddy lists are merely extensions of this and will be left as an exercise to the reader.

This article assumes a working knowledge of PHP, MySQL and JavaScript, but novice programmers should be able to follow along without too much difficulty. If you are planning on implementing a production chat server, refer to the additional notes for a couple pointers and considerations.

What you'll need

jenChat will be powered by PHP and MySQL, but we could just as easily write it using ASP and MS SQL. In fact, most of the work is done client-side via JavaScript, and even that is kept to a bare minimum. See the end product to get an idea of what we'll be making. If you want to skip the tutorial and start customizing jenChat immediately, you can go straight to the code.

A few ground rules

Before we get started, let's establish some requirements for our chat server. There's nothing worse than having to start over because you didn't plan fully and efficiently.

  1. The chat application should work across all major platforms.
  2. There should be no flickering in the chat window (in other words, we shouldn't merely be refreshing a page)
  3. It should be scalable—there should be no theoretical upper limit on the number of participants. (see the note on scalability)
  4. It should use minimal bandwidth—communication between client and server should be kept to a minimum.
  5. If a user does navigate away from the chat window, his/her session will end automatically.

Let's focus briefly on the first point: compatibility. If you wish to code for Netscape 4, feel free, but in this article we will only focus on modern browsers that support the W3C DOM, such as Internet Explorer 5+, Opera and Mozilla. It is the opinion of the author that there are enough options available on all platforms that users should not confine themselves to obsolete browsers.

The (X)HTML

In programming, it's generally a good idea to design the basic UI before implementing the functionality. Incidentally, this will help us tackle the biggest problem right off the bat—the issue of page reloads/flickering. Normally when a user wants the most up-to-date version of a page, he/she must send a new request to the server, which causes the page to refresh. By the same token, when a user submits information via a form, the server sends back a page, which in most browsers causes a flicker as it is loaded. The solution to both of these problems is a hidden IFRAME. Let's set up a basic page to illustrate how this can be done:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US" xml:lang="en-US">
  <head>
    <title>jenChat</title>
    <style type="text/css">
      #chatContents{height:300px; width:200px;}
    </style>
  </head>
  <body>
    <h1>jenChat</h1>

    <a href="login.php?logout=true">Logout</a><br />

    <iframe id="chatContents" name="chatContents" src="contents.html"></iframe>

    <form target="post" method="post" action="post.php">
      <input type="text" name="message" id="message" style="width: 250px" />
      <input type="submit" value="Send" class="submit" />
    </form>

    <iframe id="post" name="post" src="post.php"
      style="width: 0px; height: 0px; border: 0px;"></iframe>
    <iframe id="thread" name="thread" src="thread.php"
      style="width: 0px; height: 0px; border: 0px;"></iframe>
  </body>
</html>

Save this page as "chat.php". This will be the backbone of our chat application. Let's look at what's there.

First, notice the "chatContents" IFRAME, where the chat messages will appear. The page we load in the IFRAME is a simple XHTML document with a div for the contents of the chat. As we will see later, because one of our goals is to avoid any flickering, we will never actually reload the contents of this IFRAME. Rather, we will programmatically append messages to it. So why use an IFRAME at all? Why not just use a div? The reason is that there is no W3C standard for programmatically scrolling an element. But virtually all modern browsers provide a means of causing an IFRAME to scroll a specified amount, and so we use one here.

Following the chat contents is a simple form with an input and a submit button. Notice something interesting—the target of the form is "post". This means that when we submit this form, data will be submitted via the IFRAME named "post" rather than the main window. Because of this, the contents of the IFRAME will reload each time we send a message, but the main page will not. The second IFRAME will be the all-purpose worker frame; rather than reload the current page to get new messages, we will use "thread" instead. This will help us create the illusion that the server is pushing content to the browser.

The Database

The next step is to set up our database so that we have a central location for all the messages of the chat. We will set up two tables—one for participants and another for messages. The participants table will have just three columns—a unique id, a username/handle and a timestamp of when the user last communicated with the server. The messages table will store the id of the participant that submitted it, the message itself, a timestamp of when it was sent, and a unique id. This is the SQL used to create the tables:

CREATE TABLE jenChat_Users (
  UserID int(10) unsigned NOT NULL auto_increment,
  UserName varchar(20) NOT NULL default '',
  LastUpdate timestamp(14) NOT NULL,
  PRIMARY KEY  (UserID)
);
CREATE TABLE jenChat_Messages (
  MessageID int(10) unsigned NOT NULL auto_increment,
  UserID int(10) unsigned NOT NULL default '0',
  Posted timestamp(14) NOT NULL,
  Message varchar(255) NOT NULL default '',
  PRIMARY KEY  (MessageID)
);

With regards to database performance, see the note on scalability for ways to make jenChat as fast as possible, such as using a MySQL-based session handler and MEMORY (HEAP) tables.

A note about our implementation: if you plan on integrating this into an existing web application that already tracks users, you should probably use your existing table, as well as make other necessary changes. The reason is that in our chat server, participants only exist for as long as they are in the chat room—we will delete participants once they have exited. By the same token, we also delete messages shortly after they are received by the server. This will help keep the size of the database to a bare minimum, even if there is a significant amount of activity. Your application may have different needs/requirements, so some things we do in this example may not be fully applicable.

The PHP

First, we will create an include file that does a few important things for us, like initialize our session and database connections. Change the appropriate database information and save this file as "init.php". Be sure to read the comments and links in this file.

<?php
  session_start();
  
  /*
   * replace the parameters used here with the appropriate information
   * for your system.
   */
  $dbhandle = mysql_connect("server","user","password");
  mysql_select_db("database_name");

    
  /*
   * IMPORTANT: magic quotes are bad. Ideally, you should turn them off 
   * in your php.ini, but if you are unable to, the code below will fix 
   * the $_POST array for you.
   * 
   * See http://www.php.net/manual/en/security.magicquotes.php
   * 
   * If you aren't using prepared statements (mysqli, Pear:DB) or manually  
   * escaping every variable that goes into a query, you are asking to get
   * pwned. For maximum portability, jenChat uses mysql_real_escape_string,
   * but prepared statements are generally the way to go.
   * 
   * If you didn't understand that last paragraph (or even if you
   * did), read up on SQL Injection and why you need to worry about it.
   *   
   * http://www.unixwiz.net/techtips/sql-injection.html
   * 
   * OK, carry on
   */
   
  if(get_magic_quotes_gpc()){
    $_POST = array_map('stripslash', $_POST);
  }
  function stripslash($value){
    if(is_array($value))
      return array_map('stripslash', $value);
    else
      return stripslashes($value);
  }
?>

Because this tutorial is an exercise in remote scripting and not in creating user logins and so forth, I have provided a simple login script for use with the chat server. Whenever someone wants to join the chat, this script creates a new participant (if the requested handle is available) and then redirects the user to the chat page. Download the file and save it as "login.php".

Our next task is to store messages in the database as soon as they are sent. Let's create "post.php", the first of our two IFRAMEs. This script will actually serve an additional purpose—garbage collection. It deletes any messages that are over 30 seconds old, as well as any inactive participants. Inactive does not mean they are simply lurking—it means their browser is no longer communicating with the chat server, e.g. they closed the browser, navigated elsewhere, etc. Here is the basic source code:

<?php
  require_once('init.php');

  /* make sure the person is logged in. */
  if(!isset($_SESSION['jenChat_UserID']))
    exit;
  
  /* make sure something was actually posted. */
  if(sizeof($_POST)){
    $expiretime = date("YmdHis",time() - 30);

    /* delete expired messages. */
    mysql_query("DELETE FROM jenChat_Messages 
                 WHERE Posted <= '" . $expiretime . "'"); 
    /* delete inactive participants. */
    mysql_query("DELETE FROM jenChat_Users 
                 WHERE LastUpdate <= '" . $expiretime. "'"); 
    /* post the message. */
    mysql_query("INSERT INTO jenChat_Messages (UserID,Posted,Message)
                 VALUES(
                  " . $_SESSION['jenChat_UserID'] . ",
                  '" . date("YmdHis", time()) . "',
                  '" . mysql_real_escape_string(strip_tags($_POST['message'])) . "'
                 )");
  
    header("Location: post.php");
    exit;
  }
?>

You may wonder why we redirect to the same page, especially considering that the page has no content. While this does impose an additional hit on the server every time a message is posted, it helps avoid navigation woes that would otherwise be an issue. Normally, when you post to a URL several times in succession, it will be added to your browser's history each time. This is true of IFRAMEs as well as normal windows, and in this case would make the "Back" button useless. But if you simply request the same URL multiple times in a row via GET, it is only added to your history the very first time (IE 5.0 Win and Mozilla 0.6 are the exceptions). By sending this header to browsers, the history problem is history.

Next we will create our script that will poll the server for new messages, namely "thread.php". All this script needs to do is grab any new messages that did not exist the last time it talked to the server.

<?php
  require_once('init.php');

  /* make sure the person is logged in. */
  if(!isset($_SESSION['jenChat_UserID']))
    exit;

  $currtime = date("YmdHis",time());

  /* maintains this user's state as active. */
  mysql_query("UPDATE jenChat_Users SET LastUpdate = '" . $currtime . "'
                WHERE UserID = " . $_SESSION['jenChat_UserID']);

  /* grab any messages posted since the last time we checked.
  Notice we say >= and <. This is to guarantee that we don't miss any
  messages that are posted at the same instant this query is
  executed.*/
  $sql = "SELECT Message,UserName
          FROM jenChat_Messages
          INNER JOIN " . "jenChat_Users
            ON jenChat_Messages.UserID = jenChat_Users.UserID 
          WHERE Posted >= '" . $_SESSION['jenChat_Prevtime'] . "' 
            AND Posted < '" . $currtime . "'
          ORDER BY Posted";
  $res = mysql_query($sql);
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US" xml:lang="en-US">
  <head></head>
  <body>
  <?
    if(mysql_num_rows($res)){
      echo '<div id="contents">';
      while($row = mysql_fetch_array($res)){
        echo '<div><strong>' .
              htmlspecialchars($row['UserName']) . ': </strong>' .
              htmlspecialchars($row['Message']) . '</div>';
      }
      echo '</div>';
    }
    $_SESSION['jenChat_Prevtime'] = $currtime;
  ?>
  </body>
</html>

Now every time we refresh this page, if there are any new messages, they will be put in divs with the corresponding handle.

Our last bit of PHP goes in "chat.php". When they first view the page, we need verify that they are actually in the chat room (logged in). The first "if" statement verifies that they are in fact authenticated, and the second one verifies their session hasn't timed out. Insert the following code at the beginning of your document:

<?php
  session_start();
  if(!$_SESSION['jenChat_UserID']){
    header("Location: ./login.php");
    exit;
  }
  else if(date("YmdHis",time() - 5) > $_SESSION['jenChat_Prevtime']){
    header("Location: ./login.php?logout=true");
    exit;
  }
?>

The JavaScript

The final step is to tie our pages together via JavaScript. Our remaining tasks are to cause "thread.php" to auto-refresh at regular intervals, to append its contents into our chatContents area in "chat.php", and to auto-scroll chatContents as it's updated.

Because the messages are being submitted via "post.php", the contents of the form field are never cleared automatically. Every time "post.php" reloads, this script will call a function in the parent frame to reset the text field and give it focus. The following goes immediately after the PHP in "post.php":

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US" xml:lang="en-US">
  <head>
    <script type="text/javascript"><!--
      if(parent.resetForm)
        parent.resetForm();
      //-->
    </script>
  </head>
</html>

This next snippet of code should go into "thread.php" right before the closing body tag. Each time the page loads, this will call a function in the parent to notify it if there are new messages from the server. It will then schedule a refresh to poll the server again in one second (or the interval of your choice).

<script type="text/javascript"><!--
  if(parent.insertMessages && document.getElementById("contents"))
    parent.insertMessages(document.getElementById("contents").innerHTML);
  
  setTimeout("getMessages()",1000); //poll server again in one second

  function getMessages(){
    document.location.reload();
  }
  //-->
</script>

The last chunk of code goes into "chat.php" in the document head. This is where it all comes together. To start off, the function chat_init is called when the page loads. This sets up cDocument and cWindow—references to the document and window objects of our chat area.

As you'll remember from above, once "thread.php" has loaded, the getMessages function passes any new messages to insertMessages, which in turn will append them to the chatContents area. After appending the messages, we scroll to the bottom of the IFRAME.

Finally, as explained earlier, the function resetForm simply resets the text field and gives it focus each time a message is sent. Here is the code for "chat.php":

<script type="text/javascript"><!--
  var cDocument;
  var cWindow;

  window.onload = chat_init;

  function chat_init(){
    var chatContents = document.getElementById("chatContents");

    //set up a reference to the window object of the IFRAME
    if(window.frames && window.frames["chatContents"]) //IE5, Konq, Safari
      cWindow = window.frames["chatContents"];
    else if(chatContents.contentWindow) //IE5.5+, Moz 1.0+, Opera
      cWindow = chatContents.contentWindow;
    else //Moz < 0.9 (Netscape 6.0)
      cWindow = chatContents;

    //set up a reference to the document object of the IFRAME
    if(cWindow.document) //Moz 0.9+, Konq, Safari, IE, Opera
      cDocument = cWindow.document;
    else //Moz < 0.9 (Netscape 6.0)
      cDocument = cWindow.contentDocument;
  }
  function insertMessages(content){
    //place the new messages in a div
    var newDiv = cDocument.createElement("DIV");
    newDiv.innerHTML = content;

    //append the messages to the contents
    cDocument.getElementById("contents").appendChild(newDiv);

    //scroll the chatContents area to the bottom
    cWindow.scrollTo(0,cDocument.getElementById("contents").offsetHeight);
  }
  function resetForm(){
    document.getElementById("message").value = "";
    document.getElementById("message").focus();
  }//-->
</script>

Conclusion

And with that, we're done. Go ahead and give it a spin in your favorite browser(s). In case you think you may have missed something, you can view/download the individual files below, or download them all in a zipped archive (jenChat.zip). If you'd like to see a demo, try this slightly optimized version.

Above and beyond

Now that wasn't so bad—we created a working chat application in no time at all. While it's true that jenChat is lacking in features, its simplicity makes it very easy to customize. Inventive coders should have no trouble adding things such as rooms, buddy lists, emoticons, and other features that help make chat such a powerful tool and pleasant waste of time.

Questions? Corrections? Suggestions? Email me. Feel free to use these scripts throughout your site with or without modification or acknowledgement, although a link to me is always appreciated. Email me if you would like permission to reproduce this article.

Additional Notes

Scalability

I have had many questions regarding the scalability of jenChat. It scales remarkably well if you use a MySQL-based session handler and store all tables in memory (including the sessions). If you do not, you will very quickly hit the bottleneck that is your hard disk.

Every time a client polls the server there are two database queries, as well as a read and write of the PHP session data. That's on top of everything Apache/PHP does to read/parse/execute the PHP script. We're talking multiple reads/writes to multiple files for every user every second. With a significant number of users, your hard disk will have a hard time keeping up. So by moving the sessions to the database, and the database to memory, the slowdown is greatly reduced.

Using in-memory tables, I found that 25 concurrent users accessing a P4 1.4GHz machine over a LAN cost Apache about 4 MB of memory and kept CPU utilization right around 20%. YMMV. Note that you can only use AUTO_INCREMENT columns on MEMORY tables as of MySQL 4.1, so if you have an earlier version you should either upgrade or rethink your database schema.

Here are some other ideas you may want to try to speed it up even further:

Bandwidth

If you follow the suggestions above, your next bottleneck will probably be bandwidth. jenChat is designed to use as little bandwidth as possible; how much you need is directly proportional to the number of concurrent users. You may consider stripping out extraneous markup from the polling responses, though may not make much of a dent, seeing as the number of packets sent/received will not change significantly.

As a baseline, consider that there will be two TCP packets per user per second (polling request and response). On top of that you will also have the packets sent in posting messages to the server. So in just about any scenario, if you anticipate 4 packets (max 512 bytes each) per user per second, you should be able to determine your user/bandwidth limits. Remember that these numbers include both directions, with the outbound packets being the larger ones. So if you are only concerned about outgoing bandwidth, you will be more than covered if you plan on needing 1K per user per second.

If bandwidth is an issue, you could always increase the polling interval, but that would obviously affect the responsiveness.

Responsiveness

Because jenChat polls the server once a second, you may notice a delay between when you post a message and when you actually see it. It is very easy to modify the code so that it appears instantaneous, just as other chat applications do. The first modification is to append the message to the contents via JavaScript each time you hit send. The second is to alter the SQL query that polls the server to ignore messages from the user requesting them, since they are already being appended to the contents.

AJAX

Many people have requested/suggested I rewrite jenChat using AJAX. While I use XMLHTTPRequest for most of my remote scripting nowadays, I have been reluctant to update jenChat for the following reasons: I want to keep jenChat simple and have it support as many browsers as possible. Changing it to an AJAX-only script would exclude older versions of all currently supported browsers. I could make it use AJAX or IFRAMEs depending on browser support, but that would further complicate the code and tutorial. From a server/bandwidth point of view, AJAX would not make it run any more efficiently; after all, the same amount of data would be transmitted with each request.

However, AJAX does bring some significant benefits to the client. It eliminates the loading/status indicators that get triggered with each reload, and it requires less CPU and memory than an IFRAME.

AdBlock

If you use Mozilla and have AdBlock installed, remember that sloppiness in setting up your regular expressions and wildcards can cause unexpected results. jenChat stopped working for me in Firefox one day because I had set up an over-inclusive regexp filter that blocked anything ending with "ad", effectively disabling thread.php.

Compatibility

If you have tested jenChat on any other browsers/platforms, please let me know if/how it worked.

Mozilla 0.6+ includes Netscape 6+, Phoenix, Firebird, Firefox, and just about any Gecko-based browser.

Related Pages