diff --git a/js/piwik.js b/js/piwik.js
index 05af4dbab83294e39059e26094c6a45a27f6fb1c..16701cf08c1f827da04fd35744114ac38eb09ff7 100644
--- a/js/piwik.js
+++ b/js/piwik.js
@@ -2198,7 +2198,7 @@ if (typeof Piwik !== 'object') {
                 configMinimumVisitTime,
 
                 // Recurring heart beat after initial ping (in milliseconds)
-                configHeartBeatTimer,
+                configHeartBeatDelay,
 
                 // Disallow hash tags in URL
                 configDiscardHashTag,
@@ -2287,10 +2287,13 @@ if (typeof Piwik !== 'object') {
                 linkTrackingEnabled = false,
 
                 // Guard against installing the activity tracker more than once per Tracker instance
-                activityTrackingInstalled = false,
+                heartBeatSetUp = false,
 
-                // Last activity timestamp
-                lastActivityTime,
+                // Timestamp of last tracker request sent to Piwik
+                lastTrackerRequestTime,
+
+                // Handle to the current heart beat timeout
+                heartBeatTimeout,
 
                 // Internal state of the pseudo click handler
                 lastButton,
@@ -2516,6 +2519,9 @@ if (typeof Piwik !== 'object') {
              * Send request
              */
             function sendRequest(request, delay, callback) {
+                if (request.indexOf('ping=') === -1) {
+                    lastTrackerRequestTime = (new Date()).getTime();
+                }
 
                 if (!configDoNotTrack && request) {
                     makeSureThereIsAGapAfterFirstTrackingRequestToPreventMultipleVisitorCreation(function () {
@@ -2528,6 +2534,10 @@ if (typeof Piwik !== 'object') {
                         setExpireDateTime(delay);
                     });
                 }
+
+                if (configHeartBeatDelay) {
+                    heartBeatUp();
+                }
             }
 
             function canSendBulkRequest(requests)
@@ -2617,16 +2627,6 @@ if (typeof Piwik !== 'object') {
                 }
             }
 
-            /*
-             * Process all "activity" events.
-             * For performance, this function must have low overhead.
-             */
-            function activityHandler() {
-                var now = new Date();
-
-                lastActivityTime = now.getTime();
-            }
-
             /*
              * Generate a pseudo-unique ID to fingerprint this user
              * 16 hexits = 64 bits
@@ -3183,47 +3183,84 @@ if (typeof Piwik !== 'object') {
                 sendRequest(request, configTrackerPause);
 
                 // send ping
-                if (configMinimumVisitTime && configHeartBeatTimer && !activityTrackingInstalled) {
-                    activityTrackingInstalled = true;
-
-                    // add event handlers; cross-browser compatibility here varies significantly
-                    // @see http://quirksmode.org/dom/events
-                    addEventListener(documentAlias, 'click', activityHandler);
-                    addEventListener(documentAlias, 'mouseup', activityHandler);
-                    addEventListener(documentAlias, 'mousedown', activityHandler);
-                    addEventListener(documentAlias, 'mousemove', activityHandler);
-                    addEventListener(documentAlias, 'mousewheel', activityHandler);
-                    addEventListener(windowAlias, 'DOMMouseScroll', activityHandler);
-                    addEventListener(windowAlias, 'scroll', activityHandler);
-                    addEventListener(documentAlias, 'keypress', activityHandler);
-                    addEventListener(documentAlias, 'keydown', activityHandler);
-                    addEventListener(documentAlias, 'keyup', activityHandler);
-                    addEventListener(windowAlias, 'resize', activityHandler);
-                    addEventListener(windowAlias, 'focus', activityHandler);
-                    addEventListener(windowAlias, 'blur', activityHandler);
-
-                    // periodic check for activity
-                    lastActivityTime = now.getTime();
-                    setTimeout(function heartBeat() {
-                        var requestPing;
-                        now = new Date();
-
-                        // there was activity during the heart beat period;
-                        // on average, this is going to overstate the visitDuration by configHeartBeatTimer/2
-                        if ((lastActivityTime + configHeartBeatTimer) > now.getTime()) {
-                            // send ping if minimum visit time has elapsed
-                            if (configMinimumVisitTime < now.getTime()) {
-                                requestPing = getRequest('ping=1', customData, 'ping');
-
-                                sendRequest(requestPing, configTrackerPause);
-                            }
+                if (configHeartBeatDelay) {
+                    setUpHeartBeat();
+                }
+            }
 
-                            // resume heart beat
-                            setTimeout(heartBeat, configHeartBeatTimer);
-                        }
-                        // else heart beat cancelled due to inactivity
-                    }, configHeartBeatTimer);
+            /*
+             * Setup event handlers and timeout for initial heart beat.
+             */
+            function setUpHeartBeat() {
+                if (heartBeatSetUp) {
+                    return;
                 }
+
+                heartBeatSetUp = true;
+
+                addEventListener(windowAlias, 'focus', function () {
+                    // since it's possible for a user to come back to a tab after several hours or more, we try to send
+                    // a ping if the page is active. (after the ping is sent, the heart beat timeout will be set)
+                    if (heartBeatPingIfActivity()) {
+                        return;
+                    }
+
+                    heartBeatUp();
+                });
+                addEventListener(windowAlias, 'blur', function () {
+                    heartBeatDown();
+                });
+
+                heartBeatUp();
+            }
+
+            /*
+             * Sets up the heart beat timeout.
+             */
+            function heartBeatUp(delay) {
+                if (heartBeatTimeout) {
+                    return;
+                }
+
+                heartBeatTimeout = setTimeout(function heartBeat() {
+                    heartBeatTimeout = null;
+                    if (heartBeatPingIfActivity()) {
+                        return;
+                    }
+
+                    var now = new Date(),
+                        heartBeatDelay = configHeartBeatDelay - (now.getTime() - lastTrackerRequestTime);
+                    heartBeatDelay = Math.min(configHeartBeatDelay, heartBeatDelay); // sanity check
+                    heartBeatUp(heartBeatDelay)
+                }, delay || configHeartBeatDelay);
+            }
+
+            /*
+             * Removes the heart beat timeout.
+             */
+            function heartBeatDown() {
+                if (!heartBeatTimeout) {
+                    return;
+                }
+
+                clearTimeout(heartBeatTimeout);
+                heartBeatTimeout = null;
+            }
+
+            /*
+             * If there was user activity since the last check, and it's been configHeartBeatDelay seconds
+             * since the last tracker, send a ping request (the heartbeat timeout will be reset by sendRequest).
+             */
+            function heartBeatPingIfActivity() {
+                var now = new Date();
+                if (lastTrackerRequestTime + configHeartBeatDelay < now.getTime()) {
+                    var requestPing = getRequest('ping=1', null, 'ping');
+                    sendRequest(requestPing, configTrackerPause);
+
+                    return true;
+                }
+
+                return false;
             }
 
             /*
@@ -4962,14 +4999,18 @@ if (typeof Piwik !== 'object') {
                 /**
                  * Set heartbeat (in seconds)
                  *
-                 * @param int minimumVisitLength
-                 * @param int heartBeatDelay
+                 * @param int heartBeatDelay Defaults to 15.
                  */
-                setHeartBeatTimer: function (minimumVisitLength, heartBeatDelay) {
-                    var now = new Date();
+                setHeartBeatTimer: function (heartBeatDelay) {
+                    configHeartBeatDelay = (heartBeatDelay || 15) * 1000;
+                },
 
-                    configMinimumVisitTime = now.getTime() + minimumVisitLength * 1000;
-                    configHeartBeatTimer = heartBeatDelay * 1000;
+                /**
+                 * Clear heartbeat.
+                 */
+                clearHeartBeat: function () {
+                    heartBeatDown();
+                    configHeartBeatDelay = null;
                 },
 
                 /**
diff --git a/tests/javascript/index.php b/tests/javascript/index.php
index 4be71f1d7931eec159086995755bc445b9c7195f..f45bb06dace60e06b2910a18cf23a425797651fe 100644
--- a/tests/javascript/index.php
+++ b/tests/javascript/index.php
@@ -18,6 +18,9 @@ function getToken() {
 function getContentToken() {
     return "<?php $token = md5(uniqid(mt_rand(), true)); echo $token; ?>";
 }
+function getHeartbeatToken() {
+    return "<?php $token = md5(uniqid(mt_rand(), true)); echo $token; ?>";
+}
 <?php
 $sqlite = false;
 if (file_exists("enable_sqlite")) {
@@ -231,7 +234,7 @@ function triggerEvent(element, type, buttonNumber) {
      }
  }
 
- function fetchTrackedRequests(token)
+ function fetchTrackedRequests(token, parse)
  {
      var xhr = window.XMLHttpRequest ? new window.XMLHttpRequest() :
          window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") :
@@ -240,7 +243,18 @@ function triggerEvent(element, type, buttonNumber) {
      xhr.open("GET", "piwik.php?requests=" + token, false);
      xhr.send(null);
 
-     return xhr.responseText;
+     var response = xhr.responseText;
+     if (parse) {
+         var results = [];
+         $(response).filter('span').each(function (i) {
+             if (i != 0) {
+                 results.push($(this).text());
+             }
+         });
+         return results;
+     }
+
+     return response;
  }
 
  function dropCookie(cookieName, path, domain) {
@@ -3087,6 +3101,84 @@ if ($sqlite) {
         }, 5000);
     });
 
+    // heartbeat tests
+    test("trackingHeartBeat", function () {
+        expect(11);
+
+        var tokenBase = getHeartbeatToken();
+
+        var tracker = Piwik.getTracker();
+        tracker.setTrackerUrl("piwik.php");
+        tracker.setSiteId(1);
+        tracker.setHeartBeatTimer(1);
+
+        // test ping heart beat not set up until an initial request tracked
+        tracker.setCustomData('token', 1 + tokenBase);
+        wait(1200);
+
+        // test ping not sent on initial page load, and sent if inactive for N secs.
+        tracker.setCustomData('token', 2 + tokenBase);
+        tracker.trackPageView('whatever'); // normal request sent here
+        triggerEvent(document.body, 'focus');
+
+        wait(1200); // ping request sent after this
+
+        // test ping not sent after N secs, if tracking request sent in the mean time
+        tracker.setCustomData('token', 3 + tokenBase);
+
+        wait(200);
+        tracker.trackPageView('whatever2'); // normal request sent here
+        wait(200);
+
+        wait(1000); // ping request NOT sent here
+
+        // test ping sent N secs after second tracking request if inactive.
+        tracker.setCustomData('token', 4 + tokenBase);
+
+        wait(1000); // ping request sent here
+
+        // test ping not sent N secs after, if window blur event triggered (ie tab switch) and N secs pass.
+        tracker.setCustomData('token', 5 + tokenBase);
+        triggerEvent(document.body, 'blur');
+
+        wait(1000); // ping request not sent here
+
+        // test ping sent immediately if tab switched and more than N secs pass, then tab switched back
+        tracker.setCustomData('token', 6 + tokenBase);
+
+        triggerEvent(document.body, 'focus'); // ping request sent here
+
+        stop();
+        setTimeout(function() {
+            var token;
+
+            var requests = fetchTrackedRequests(token = 1 + tokenBase, true);
+            equal(requests.length, 0, "no requests sent before initial non-ping request sent");
+
+            requests = fetchTrackedRequests(token = 2 + tokenBase, true);
+            ok(/action_name=whatever/.test(requests[0]) && !(/ping=1/.test(requests[0])), "first request is page view not ping");
+            ok(/ping=1/.test(requests[1]), "second request is ping request");
+            equal(requests.length, 2, "only 2 requests sent for normal ping");
+
+            requests = fetchTrackedRequests(token = 3 + tokenBase, true);
+            ok(/action_name=whatever2/.test(requests[0]) && !(/ping=1/.test(requests[0])), "first request is page view not ping");
+            equal(requests.length, 1, "no ping request sent if other request sent in meantime");
+
+            requests = fetchTrackedRequests(token = 4 + tokenBase, true);
+            ok(/ping=1/.test(requests[0]), "ping request sent if no other activity and after heart beat");
+            equal(requests.length, 1, "only ping request sent if no other activity");
+
+            requests = fetchTrackedRequests(token = 5 + tokenBase, true);
+            equal(requests.length, 0, "no requests sent if window not in focus");
+
+            requests = fetchTrackedRequests(token = 6 + tokenBase, true);
+            ok(/ping=1/.test(requests[0]), "ping sent after window regains focus");
+            equal(requests.length, 1, "only one ping request sent after window regains focus");
+
+            start();
+        }, 6000);
+    });
+
     test("trackingContent", function() {
         expect(81);
 
@@ -3566,9 +3658,7 @@ if ($sqlite) {
 
             start();
         }, 4000);
-
     });
-
     <?php
 }
 ?>