diff --git a/cps.install b/cps.install
index d10222e..5e72b8b 100644
--- a/cps.install
+++ b/cps.install
@@ -167,7 +167,6 @@ function cps_schema() {
       'changeset_id' => array('changeset_id')
     ),
   );
-
   return $schema;
 }
 
@@ -622,3 +621,4 @@ function cps_update_7108() {
 
   db_add_field('cps_changeset', 'lock_in_select', $field);
 }
+
diff --git a/cps.module b/cps.module
index 2110921..bf4b0b2 100644
--- a/cps.module
+++ b/cps.module
@@ -10,6 +10,7 @@ define('CPS_INSTALLED_CHANGESET', 'installed');
 
 define('CPS_LIVE_STATUS', 'live');
 define('CPS_ARCHIVED_STATUS', 'archived');
+define('CPS_UNPUBLISHED_STATUS', 'unpublished');
 
 require_once __DIR__ . '/includes/query.inc';
 
@@ -17,55 +18,6 @@ require_once __DIR__ . '/includes/query.inc';
 // Drupal Core Hooks
 
 /**
- * Implements hook_boot().
- */
-function cps_boot() {
-  // If we are looking at a changeset, load overridden variables and add
-  // them into $conf.
-
-  // This isn't using cps_changeset_load() because that loads a bunch of things
-  // that don't exist during hook_boot.
-  if (variable_get('cps_override_variables', FALSE)) {
-    $changeset_id = cps_get_current_changeset();
-    if ($changeset_id != CPS_PUBLISHED_CHANGESET) {
-      $vars = db_query("SELECT variables FROM {cps_changeset} WHERE changeset_id = :changeset_id", array(':changeset_id' => $changeset_id))->fetchField();
-      if ($vars) {
-        global $conf;
-        global $cps_conf;
-        $cps_conf = unserialize($vars);
-
-        if ($cps_conf) {
-          foreach ($cps_conf as $name => $value) {
-            $conf[$name] = $value;
-          }
-        }
-      }
-    }
-  }
-}
-
-/**
- * Implements hook_exit().
- */
-function cps_exit() {
-  if (variable_get('cps_override_variables', FALSE)) {
-    global $cps_conf_changed;
-    if (!empty($cps_conf_changed)) {
-      $changeset_id = cps_get_current_changeset(TRUE);
-      if ($changeset_id != CPS_PUBLISHED_CHANGESET) {
-        $changeset = cps_changeset_load($changeset_id);
-        // Only write if $changeset->published is empty.
-        if (empty($changeset->published)) {
-          global $cps_conf;
-          $changeset->variables = $cps_conf;
-          $changeset->save();
-        }
-      }
-    }
-  }
-}
-
-/**
  * Implements hook_module_implements_alter().
  */
 function cps_module_implements_alter(&$implementations, $hook) {
@@ -326,10 +278,39 @@ function cps_menu() {
     'file' => 'includes/admin.inc',
   );
 
+  $items['admin/structure/changesets/process/%/%'] = array(
+    'title' => t('Process queue'),
+    'page callback'=> 'cps_process_queue_page',
+    'page arguments'=> array(4, 5),
+    'access arguments' => array('publish changesets'),
+    'type' => MENU_CALLBACK,
+    'file' => 'includes/admin.inc',
+  );
+
   return $items;
 }
 
 /**
+ * Implements hook_enable().
+ */
+function cps_enable() {
+  $queue = DrupalQueue::get('cps_publish');
+  $queue->createQueue();
+  $queue = DrupalQueue::get('cps_revert');
+  $queue->createQueue();
+}
+
+/**
+ * Implements hook_disable().
+ */
+function cps_disable() {
+  $queue = DrupalQueue::get('cps_publish');
+  $queue->deleteQueue();
+  $queue = DrupalQueue::get('cps_revert');
+  $queue->deleteQueue();
+}
+
+/**
  * Implements hook_entity_load().
  */
 function cps_entity_load(&$entities, $entity_type) {
@@ -951,75 +932,25 @@ function cps_get_tracked_entities($changeset_id) {
 /**
  * Publish a changeset.
  *
- * @param $changeset_id
- *   A changeset ID.
+ * @param $changeset
+ *   A changeset
  */
-function cps_publish_changeset_entities($changeset_id) {
+function cps_publish_changeset_entities($changeset, $total, $processed) {
   // Get all tracked entities in the changeset.
-  $changed = cps_get_tracked_entities($changeset_id);
-  $transaction = db_transaction();
-  try {
-    foreach ($changed as $entity_type => $ids) {
-      foreach ($ids as $entity_id => $revision_id) {
-        cps_make_revision_published($entity_type, $entity_id, $changeset_id);
-      }
+  $changed = cps_get_tracked_entities($changeset->identifier());
+  foreach ($changed as $entity_type => $ids) {
+    foreach ($ids as $entity_id => $revision_id) {
+      // Renew the processing lock.
+      lock_acquire('cps_process');
+      // Add two seconds to the time limit for each entity processed.
+      drupal_set_time_limit(2);
+      cps_make_revision_published($entity_type, $entity_id, $changeset->identifier());
+      $processed++;
+      cps_notify_progress($changeset, $total, $processed);
     }
   }
-  catch (Exception $e) {
-    $transaction->rollback();
-    trigger_error(t('Unable to publish entities in changeset'), E_USER_ERROR);
-    watchdog_exception('CPS', $e);
-  }
 }
 
-/**
- * Unpublish a changeset.
- *
- * @param $changeset_id
- *   A changeset ID.
- */
-function cps_unpublish_changeset_entities($changeset_id, $previous_changeset_id) {
-  $changed = cps_get_tracked_entities($changeset_id);
-  $transaction = db_transaction();
-  try {
-    foreach ($changed as $entity_type => $ids) {
-      foreach ($ids as $entity_id => $revision_id) {
-        // If the entity existed in the previous changeset ID, use that version.
-        $vid = db_query('SELECT revision_id FROM {cps_entity} WHERE entity_type = :entity_type AND entity_id = :entity_id AND changeset_id = :changeset_id', array(
-          'entity_type' => $entity_type,
-          'entity_id' => $entity_id,
-          'changeset_id' => $previous_changeset_id,
-        ))->fetchField();
-        if ($vid) {
-          cps_make_revision_published($entity_type, $entity_id, $previous_changeset_id);
-        }
-        // Otherwise assume the entity was new in the changeset, and publish the
-        // 'initial' version.
-        else {
-          cps_make_revision_published($entity_type, $entity_id, 'initial');
-        }
-      }
-    }
-  }
-  catch (Exception $e) {
-    $transaction->rollback();
-    trigger_error(t('Unable to unpublish entities in changeset'), E_USER_ERROR);
-    watchdog_exception('CPS', $e);
-  }
-}
-
-/**
- * Remove non-tracked items from a changeset.
- *
- * @param $changeset_id
- *   A changeset ID.
- */
-function cps_remove_untracked_entities($changeset_id) {
-  db_delete('cps_entity')
-    ->condition('changeset_id', $changeset_id)
-    ->condition('published', 1)
-    ->execute();
-}
 
 /**
  * Record the published version ID for untracked entities.
@@ -1079,35 +1010,208 @@ function cps_add_untracked_entities($changeset_id, $entity_type, $start = 0, $li
 /**
  * Publish a changeset.
  *
+ * This function assumes that the global CPS publishing lock has been handled
+ * elsewhere.
+ *
  * @param CPSChangeset $changeset
  *
  * @return bool
  */
 function cps_publish_changeset($changeset) {
-  // Return FALSE now if a changeset is already being published.
-  if (!cps_publish_lock()) {
-    return FALSE;
+  // If a changeset is already published, just return TRUE.
+  if ($changeset->status == CPS_ARCHIVED_STATUS) {
+    return TRUE;
   }
 
-  cps_publish_changeset_entities($changeset->changeset_id);
+  $transaction = db_transaction();
+  try {
+    $info = entity_get_info();
+    $total = 0;
+    foreach (cps_get_supported() as $entity_type) {
+      $query = db_select($info[$entity_type]['base table']);
+      $total = $total + $query->countQuery()->execute()->fetchField();
+    }
+    $processed = 0;
+    cps_notify_progress($changeset, $total, $processed);
+
+
+    $tracked = cps_get_tracked_entities($changeset->identifier());
+    cps_publish_changeset_entities($changeset, $total, $processed);
+    $processed = $processed + count($tracked);
+    cps_notify_progress($changeset, $total, $processed);
 
-  foreach (cps_get_supported() as $entity_type) {
-    $start = 0;
+    foreach (cps_get_supported() as $entity_type) {
+      $start = 0;
 
-    do {
-      // We know there are not that many entities of any type.
-      $count = cps_add_untracked_entities($changeset->changeset_id, $entity_type, $start, 100);
-      $start += $count;
-    } while ($count > 0);
+      do {
+        // We know there are not that many entities of any type.
+        $count = cps_add_untracked_entities($changeset->changeset_id, $entity_type, $start, 100);
+        $processed = $processed + $count;
+        cps_notify_progress($changeset, $total, $processed);
+        $start += $count;
+      } while ($count > 0);
+    }
+
+    $changeset->published = time();
+    $changeset->setStatus(CPS_ARCHIVED_STATUS);
+    $changeset->save();
+    cps_notify_progress($changeset, $total, $processed, 1);
+    module_invoke_all('cps_changeset_published', $changeset, CPS_ARCHIVED_STATUS);
+    return TRUE;
   }
+  catch (Exception $e) {
+    trigger_error($e->getMessage(), E_USER_ERROR);
+    watchdog_exception('cps', $e);
+    $transaction->rollback();
+    cps_notify_progress($changeset, $total, $processed, -1);
+    return FALSE;
+  }
+}
 
-  $changeset->published = REQUEST_TIME;
-  $changeset->setStatus(CPS_ARCHIVED_STATUS);
-  $changeset->save();
+/**
+ * Revert a changeset.
+ *
+ * This function assumes that the global CPS publishing lock has been handled
+ * elsewhere.
+ *
+ * @param CPSChangeset $changeset
+ *
+ * @return bool
+ */
+function cps_revert_changeset($changeset_from) {
+  // If a changeset is already unpublished, just return TRUE.
+  if ($changeset_from->status === CPS_UNPUBLISHED_STATUS) {
+    return TRUE;
+  }
+  $changeset_to = cps_changeset_load($changeset_form->getPreviousChangeset());
 
-  cps_publish_unlock();
+  $transaction = db_transaction();
+  try {
+    $info = entity_get_info();
+    $total = 0;
+    foreach (cps_get_supported() as $entity_type) {
+      $query = db_select($info[$entity_type]['base table']);
+      $total = $total + $query->countQuery()->execute()->fetchField();
+    }
+    $processed = 0;
+    cps_notify_progress($changeset_from, $total, $processed);
+    $tracked = cps_get_tracked_entities($changeset_from->identifier());
+
+    cps_unpublish_changeset_entities($changeset_from, $changeset_to, $total, $processed);
+    cps_remove_untracked_entities($changeset_from->identifier());
+    cps_notify_progress($changeset_from, $total, $total);
+
+    $changeset_from->published = 0;
+    $changeset_from->setStatus(CPS_UNPUBLISHED_STATUS);
+    $changeset_from->save();
+    // Assume this is all entities.
+    cps_notify_progress($changeset_from, $total, $total, 1);
+    module_invoke_all('cps_changeset_published', $changeset_from, CPS_UNPUBLISHED_STATUS);
+    return TRUE;
+  }
+  catch (Exception $e) {
+    trigger_error($e->getMessage(), E_USER_ERROR);
+    watchdog_exception('cps', $e);
+    $transaction->rollback();
+    cps_notify_progress($changeset_from, $total, $processed, -1);
+    return FALSE;
+  }
+}
+/**
+ * Revert entities from one changeset to another.
+ *
+ * @param $changeset_from
+ *   The changeset reverted from.
+ * @param $changeset_to_
+ *   The changeset reverted to.
+ * @param $total
+ *   The total of CPS-enabled entities on the site, used for tracking purposes.
+ *  @param $processed
+ *    The total number of entities already processed.
+ */
+function cps_unpublish_changeset_entities($changeset_from, $changeset_to, $total = 0, $processed = 0) {
+  $changed = cps_get_tracked_entities($changeset_from->identifier());
+  foreach ($changed as $entity_type => $ids) {
+    foreach ($ids as $entity_id => $revision_id) {
+      // Renew the processing lock.
+      lock_acquire('cps_process');
+      // Add two seconds to the time limit for each entity processed.
+      drupal_set_time_limit(2);
+      // If the entity existed in the previous changeset ID, use that version.
+      $vid = db_query('SELECT revision_id FROM {cps_entity} WHERE entity_type = :entity_type AND entity_id = :entity_id AND changeset_id = :changeset_id', array(
+        'entity_type' => $entity_type,
+        'entity_id' => $entity_id,
+        'changeset_id' => $changeset_to->identifier(),
+      ))->fetchField();
+      if ($vid) {
+        cps_make_revision_published($entity_type, $entity_id, $changeset_to->identifier());
+      }
+      // Otherwise assume the entity was new in the changeset, and publish the
+      // 'initial' version.
+      else {
+        cps_make_revision_published($entity_type, $entity_id, 'initial');
+      }
+      $processed++;
+      cps_notify_progress($changeset_from, $total, $processed);
+    }
+  }
+}
 
-  return TRUE;
+/**
+ * Remove non-tracked items from a changeset.
+ *
+ * @param $changeset_id
+ *   A changeset ID.
+ */
+function cps_remove_untracked_entities($changeset_id) {
+  db_delete('cps_entity')
+    ->condition('changeset_id', $changeset_id)
+    ->condition('published', 1)
+    ->execute();
+}
+
+/**
+ * Communicates processing status.
+ *
+ * This is done in two ways:
+ *  - directly printing to the buffer
+ *  - writing to a JSON file in the public files directory.
+ *
+ *  The JSON file allows publish_status.js to poll every second to update the
+ *  browser with the number of entities processed. We use a file because AJAX
+ *  polling is simple to implement, and because writing to a database table
+ *  within a transaction may not be readable by a separate JS callback depending
+ *  on transaction isolation settings.
+ */
+function cps_notify_progress($changeset, $total, $processed, $complete = 0) {
+  // @todo: early flush doesn't actually work yet.
+  static $started = FALSE;
+  if (!$started) {
+    print '<html><head></head><body>';
+    $started = TRUE;
+  }
+  $uri = 'public://cps_process_status.json';
+  if ($complete == 0 && $total == 0) {
+    $message = t('Processing.');
+    print "<p>$message</p>";
+    file_unmanaged_save_data(json_encode(array('message' => $message, 'complete' => $complete)), $uri, FILE_EXISTS_REPLACE);
+  }
+  else if ($complete === 0) {
+    $message = t('Processed @processed / @total entities.', array('@processed' => $processed, '@total' => $total));
+    print "<p>$message</p>";
+    file_unmanaged_save_data(json_encode(array('message' => $message, 'complete' => $complete)), $uri, FILE_EXISTS_REPLACE);
+  }
+  else if ($complete === -1) {
+    $message = t('Processing failed, please try again');
+    print "<p>$message</p>";
+    file_unmanaged_save_data(json_encode(array('message' => $message, 'complete' => $complete)), $uri, FILE_EXISTS_REPLACE);
+  }
+  else {
+    $message = t('Processing completed');
+    print "<p>$message</p>";
+    file_unmanaged_save_data(json_encode(array('message' => $message, 'complete' => $complete)), $uri, FILE_EXISTS_REPLACE);
+    print '</body></html>';
+  }
 }
 
 /**
@@ -1483,53 +1587,6 @@ function cps_entity_in_unpublished_changeset($entity_type, $entity, $exclude_cur
   return $query->execute()->fetchField();
 }
 
-
-/**
- * Lock the CPS publishing system.
- *
- * @return bool
- *   TRUE if the lock can be acquired. FALSE if not.
- *
- * @see cps_publish_lock_time()
- */
-function cps_publish_lock() {
-  $time = cps_publish_lock_time();
-  if ($time) {
-    return FALSE;
-  }
-  // Set the lock to expire in 30 minutes.
-  // This seems like a long time, but some publishing runs can be quite long, and we want
-  // to make sure they finish and that a timeout truly represents an aborted publishing job,
-  // for example because the user navigated away from the batch job before it completed.
-  // Because this is intended to be used in batch processes (for which the lock needs to be
-  // held for more than one page request, until the batch job is complete) it cannot use the
-  // core lock API. Therefore it uses a variable instead.
-  variable_set('cps_publishing_lock', REQUEST_TIME + variable_get('cps_publishing_lock_expire', 60 * 30));
-  return TRUE;
-}
-
-/**
- * Unlock the CPS publishing system.
- */
-function cps_publish_unlock() {
-  variable_del('cps_publishing_lock');
-}
-
-/**
- * Test the CPS publishing lock.
- *
- * @return int
- *   The number of seconds remaining if publishing is locked, FALSE if it is not.
- */
-function cps_publish_lock_time() {
-  $lock = variable_get('cps_publishing_lock', 0);
-  if ($lock > REQUEST_TIME) {
-    return $lock - REQUEST_TIME;
-  }
-
-  return FALSE;
-}
-
 /**
  * Move or copy an entity from one changeset to another.
  *
@@ -1572,6 +1629,7 @@ function cps_move_changeset($changeset_from, $changeset_to, $entity_type, $entit
     }
   }
   catch (Exception $e) {
+    trigger_error($e->getMessage(), E_USER_ERROR);
     drupal_set_message(nt('Unable to complete operation due to an internal error. Exception recorded to watchdog log.'));
     $transaction->rollback();
     watchdog_exception('cps', $e);
@@ -1811,7 +1869,6 @@ function cps_changeset_get_operations(CPSChangeset $changeset) {
       '#text' => t('publish'),
       '#path' => $uri['path'] . '/publish',
       '#options' => array(
-        'query' => drupal_get_destination(),
         'html' => TRUE,
         'attributes' => array('class' => array('cps-changeset-operation'))
       ),
diff --git a/cps.test b/cps.test
index b28892d..9fe061e 100644
--- a/cps.test
+++ b/cps.test
@@ -222,26 +222,18 @@ class CpsTest extends CpsWebTestCase {
    * Publish a changeset.
    */
   function publishChangeset($changeset_id) {
-    // @todo: use API method when one exists.
-    $this->drupalGet("admin/structure/changesets/$changeset_id/submit");
-    $this->assertResponse(200);
-    $edit = array(
-      'message' => 'Test',
-    );
-    $this->drupalPost("admin/structure/changesets/$changeset_id/submit", $edit, t('Submit'));
-    $this->assertResponse(200);
-    $this->drupalGet("admin/structure/changesets/$changeset_id/publish");
-    $this->assertResponse(200);
-    $this->drupalPost("admin/structure/changesets/$changeset_id/publish", array(), t('Publish'));
+    // Ensure the 'published' timestamp is different from the previously
+    // published changeset.
+    sleep(1);
+    $changeset = cps_changeset_load($changeset_id);
+    cps_publish_changeset($changeset);
   }
 
   /**
    * Unpublish a changeset.
    */
   function unpublishChangeset($changeset_id) {
-    $this->drupalGet("admin/structure/changesets/$changeset_id/unpublish");
-    $this->assertResponse(200);
-    $this->drupalPost("admin/structure/changesets/$changeset_id/unpublish", array(), t('Revert'));
-    $this->assertResponse(200);
+    $changeset = cps_changeset_load($changeset_id);
+    cps_revert_changeset($changeset);
   }
 }
diff --git a/includes/CPSChangesetController.class.php b/includes/CPSChangesetController.class.php
index 3217e4f..a798683 100644
--- a/includes/CPSChangesetController.class.php
+++ b/includes/CPSChangesetController.class.php
@@ -17,8 +17,10 @@ class CPSChangesetController extends EntityAPIController {
       'changeset_id' => drupal_hash_base64(uniqid('', TRUE)),
       'name' => '',
       'uid' => $user->uid,
-      'created' => REQUEST_TIME,
-      'changed' => REQUEST_TIME,
+      // Use time() instead of REQUEST_TIME to differentiate between entities
+      // created within the same request, especially during tests.
+      'created' => time(),
+      'changed' => time(),
       'status' => 'unpublished',
     );
     return parent::create($values);
@@ -270,6 +272,34 @@ class CPSChangesetController extends EntityAPIController {
     /** @var CPSChangeset $entity */
     $build = parent::buildContent($entity, $view_mode, $langcode, $content);
 
+    $params = drupal_get_query_parameters();
+    if (isset($params['token'])) {
+      if ($params['token'] == drupal_get_token($entity->identifier())) {
+        if (!empty($params['cps_publish'])) {
+          $src =  url('admin/structure/changesets/process/publish/' . $params['token']);
+        }
+        elseif (!empty($params['cps_revert'])) {
+          $src = url('admin/structure/changesets/process/revert/' . $params['token']);
+        }
+        else {
+          return;
+        }
+        $build['process']['placeholder'] = array(
+          '#prefix' => '<div id="cps-process-placeholder" class="messages status ok">',
+          '#markup' => '<iframe src="' . $src . '"></iframe>',
+          '#suffix' => '</div>',
+        );
+        $path = drupal_get_path('module', 'cps');
+        $build['process']['#attached']['js'][] = array(
+          'type' => 'setting',
+          'data' => array(
+            'cps_process_status_url' => file_create_url('public://cps_process_status.json'),
+          ),
+        );
+        $build['process']['#attached']['js'][$path . '/js/process_status.js'] = array();
+      }
+    }
+
     $account = user_load($entity->uid);
     $build['submitted'] = array(
       '#markup' => t('Created by !username on !datetime', array('!username' => format_username($account), '!datetime' => format_date($entity->created))),
diff --git a/includes/admin.inc b/includes/admin.inc
index 68b50b3..c5319b7 100644
--- a/includes/admin.inc
+++ b/includes/admin.inc
@@ -164,7 +164,7 @@ function cps_changeset_unpublish_page(CPSChangeset $entity) {
   ) + form_state_defaults();
 
   form_load_include($form_state, 'inc', 'cps', 'includes/forms');
-  $output = drupal_build_form('cps_changeset_unpublish_changeset_form', $form_state);
+  $output = drupal_build_form('cps_changeset_revert_changeset_form', $form_state);
   return $output;
 }
 
@@ -718,3 +718,45 @@ function cps_diff_page($entity_type, $entity, $old_changeset, $new_changeset, $s
   return $build;
 }
 
+/**
+ * Callback to process the CPS publishing queue.
+ *
+ * @param $op
+ *   Either 'publish' or 'revert'.
+ *
+ * @param $token
+ *   A CSRF token.
+ */
+function cps_process_queue_page($op, $token) {
+  if (!$lock = lock_acquire('cps_process')) {
+    lock_wait('cps_process');
+    $lock = lock_acquire('cps_process');
+  }
+  if ($lock) {
+    $queue = DrupalQueue::get("cps_$op");
+    $item = $queue->claimItem();
+
+    if ($item) {
+      $changeset_id = $item->data['changeset_id'];
+      $changeset = cps_changeset_load($changeset_id);
+
+      // Prevent processing of queue items without a CSRF token matching the
+      // changeset ID. This isn't strictly necessary since adding the item to
+      // the queue is itself CSRF protected, but it ensures that queue
+      // processing only happens as a result of a direct form submission.
+      if (drupal_valid_token($token, $changeset_id)) {
+        $function = 'cps_' . $op . '_changeset';
+        if ($function($changeset)) {
+          $queue->deleteItem($item);
+        }
+        else {
+          $queue->releaseItem($item);
+        }
+      }
+    }
+    lock_release('cps_process');
+  }
+  else {
+    drupal_set_message(t('Unable to process changeset since this is being done by another process, please try again.'));
+  }
+}
diff --git a/includes/forms.inc b/includes/forms.inc
index d399fc8..0844167 100644
--- a/includes/forms.inc
+++ b/includes/forms.inc
@@ -165,153 +165,31 @@ function cps_changeset_publish_changeset_form_cancel($form, &$form_state) {
 }
 
 /**
- * Submit handler for the publish changeset form.
- */
-function cps_changeset_publish_changeset_form_submit($form, &$form_state) {
-  // This is handled as a traditional submit method rather than my usual
-  // preference of divorcing work from the form itself due to the nature
-  // of batch API.
-  $batch = array(
-    'operations' => array(
-      array('cps_changeset_publish_batch_lock', array()),
-      array('cps_changeset_publish_batch_entities', array($form_state['entity'])),
-    ),
-    'finished' => 'cps_changeset_publish_batch_finished',
-    'title' => t('Publishing %changeset', array('%changeset' => $form_state['entity']->name)),
-    'file' => drupal_get_path('module', 'cps') . '/includes/forms.inc',
-  );
-
-  if (variable_get('cps_override_variables', FALSE)) {
-    $batch['operations'][] = array('cps_changeset_publish_batch_variables', array());
-  }
-
-  foreach (cps_get_supported() as $entity_type) {
-    // For every entity type we support, add another operation for that entity type.
-    $batch['operations'][] = array('cps_changeset_publish_batch_update', array($form_state['entity'], $entity_type));
-  }
-
-  drupal_alter('cps_publish_changeset_batch', $batch, $form_state);
-
-  batch_set($batch);
-
-  $form_state['redirect'] = $form_state['entity']->uri();
-}
-
-
-/**
- * Batch API callback to acquire a lock for publishing.
- *
- * @param $entity_ids
- *   An array of arrays. Each entry in the array is first entity_type, then entity_id.
- * @param $changeset
- * @param $context
- */
-function cps_changeset_publish_batch_lock(&$context) {
-  if (cps_publish_lock()) {
-    $context['finished'] = TRUE;
-  }
-  else {
-    sleep(1);
-    $context['finished'] = FALSE;
-    $context['message'] = t('Waiting for lock: @time', array('@time' => format_interval(cps_publish_lock_time())));
-  }
-}
-
-/**
- * Batch API callback to publish the entities in a changeset.
- *
- * @param $changeset
- * @param $context
- */
-function cps_changeset_publish_batch_entities($changeset, &$context) {
-  cps_publish_changeset_entities($changeset->changeset_id);
-  $context['results']['changeset'] = $changeset;
-  $context['message'] = t('Publishing entities');
-  $context['finished'] = TRUE;
-  return $context;
-}
-
-/**
- * Batch API callback to publish the variables in a changeset.
+ * Validate handler for the publish changeset form.
  */
-function cps_changeset_publish_batch_variables(&$context) {
-  $item = $context['results']['entity'];
-  foreach ($item->variables as $name => $value) {
-    db_merge('variable')->key(array('name' => $name))->fields(array('value' => serialize($value)))->execute();
+function cps_changeset_publish_changeset_form_validate($form, &$form_state) {
+  if (!lock_may_be_available('cps_process')) {
+    form_set_error('publish', t('CPS is processing another changeset, please try again in a few seconds.'));
   }
-  cache_clear_all('variables', 'cache_bootstrap');
-  $context['message'] = t('Publishing variables');
 }
 
 /**
- * Batch API callback to put in changeset markers for items that exist but didn't change.
- * @param $entity_type
- * @param $context
+ * Submit handler for the publish changeset form.
  */
-function cps_changeset_publish_batch_update($changeset, $entity_type, &$context) {
-  $changeset_id = $changeset->changeset_id;
-
-  // Use the $context['sandbox'] at your convenience to store the
-  // information needed to track progression between successive calls.
-  if (empty($context['sandbox'])) {
-    $entity_info = entity_get_info($entity_type);
-    $context['sandbox'] = array();
-    $context['sandbox']['progress'] = 0;
-    $context['sandbox']['current_item'] = 0;
-    $context['message'] = t('Marking unchanged @entity_type content', array('@entity_type' => $entity_info['label']));
-  }
-
-  $limit = 100;
-  $count = cps_add_untracked_entities($changeset_id, $entity_type, $context['sandbox']['current_item'], $limit);
-
-  $context['results']['changeset'] = $changeset;
-
-  if (!$count) {
-    $context['finished'] = TRUE;
-  }
-  else {
-    $context['finished'] = FALSE;
-    $context['sandbox']['current_item'] = $context['sandbox']['current_item'] + $count;
-  }
+function cps_changeset_publish_changeset_form_submit($form, &$form_state) {
+  $queue = DrupalQueue::get('cps_publish');
+  $queue->createItem(array('changeset_id' => $form_state['entity']->identifier()));
+  $query = array(
+    'cps_publish' => 1,
+    'token' => drupal_get_token($form_state['entity']->identifier()),
+    'changeset_id' => $form_state['entity']->identifier(),
+  );
+  // Reset process notification.
+  cps_notify_progress($form_state['entity']->identifier(), 0, 0);
+  $uri = $form_state['entity']->uri();
+  $form_state['redirect'] = array($uri['path'] . '/status', array('query' => $query));
 }
 
-/**
- * Batch API callback to finish the submission.
- *
- * @param $success
- * @param $results
- * @param $operations
- */
-function cps_changeset_publish_batch_finished($success, $results, $operations) {
-  if ($success) {
-    $changeset = $results['changeset'];
-    // Set the publication time on the item. We have to GET the item somehow.
-    $changeset->published = REQUEST_TIME;
-    $changeset->setStatus(CPS_ARCHIVED_STATUS);
-    if (variable_get('cps_override_variables', FALSE)) {
-
-      // Rebuild the variables in the same manner that the cache is built from variable_initialize.
-      $changeset->variables = array_map('unserialize', db_query('SELECT name, value FROM {variable}')->fetchAllKeyed());
-      // Go through and unset any whitelisted variables so that they aren't stored in the archive.
-      foreach ($changeset->variables as $name => $value) {
-        if (strpos($name, 'cache') !== FALSE) {
-          unset($results['entity']->variables[$name]);
-        }
-      }
-    }
-
-    $changeset->save();
-
-    module_invoke_all('cps_changeset_published', $changeset, 'published');
-
-    // Switch them to the published changeset.
-    cps_set_current_changeset(NULL);
-
-    drupal_set_message(t('The site version %changeset has been published.', array('%changeset' => $changeset->name)));
-  }
-
-  cps_publish_unlock();
-}
 
 // -----------------------------------------------------------------------
 // Unpublish form and handlers.
@@ -319,7 +197,7 @@ function cps_changeset_publish_batch_finished($success, $results, $operations) {
 /**
  * Form callback to display the unpublish changeset form.
  */
-function cps_changeset_unpublish_changeset_form($form, &$form_state) {
+function cps_changeset_revert_changeset_form($form, &$form_state) {
   $entity = $form_state['entity'];
   $form_state['previous'] = cps_changeset_load($entity->getPreviousChangeset());
 
@@ -327,7 +205,7 @@ function cps_changeset_unpublish_changeset_form($form, &$form_state) {
     '#markup' => '<div class="unpublish-warning">' . t('<p>Are you sure you want to unpublish this?</p><p> This will revert content to the previous site version "%changeset".</p>', array('%changeset' => $form_state['previous']->name)) . '</div>',
   );
 
-  $form['actions']['unpublish'] = array(
+  $form['actions']['revert'] = array(
     '#type' => 'submit',
     '#value' => t('Revert'),
   );
@@ -344,99 +222,27 @@ function cps_changeset_unpublish_changeset_form($form, &$form_state) {
 }
 
 /**
- * Submit handler for the unpublish changeset form.
- */
-function cps_changeset_unpublish_changeset_form_submit($form, &$form_state) {
-  // We have to tell batch where to include this file.
-  $batch = array(
-    'operations' => array(
-      array('cps_changeset_publish_batch_lock', array()),
-      array('cps_changeset_unpublish_batch_variables', array($form_state['entity'], $form_state['previous'])),
-      array('cps_changeset_unpublish_batch_entity', array()),
-    ),
-    'finished' => 'cps_changeset_unpublish_batch_finished',
-    'title' => t('Reverting %changeset', array('%changeset' => $form_state['entity']->name)),
-    'file' => drupal_get_path('module', 'cps') . '/includes/forms.inc',
-  );
-
-  drupal_alter('cps_unpublish_changeset_batch', $batch, $form_state);
-
-  batch_set($batch);
-
-  $form_state['redirect'] = $form_state['entity']->uri();
-}
-
-/**
- * Batch API callback to unpublish the entities in a changeset.
+ * Form validate callback for the unpublish changeset form.
  */
-function cps_changeset_unpublish_batch_variables($item, $previous, &$context) {
-  // Use the $context['sandbox'] at your convenience to store the
-  // information needed to track progression between successive calls.
-  if (empty($context['sandbox'])) {
-    $context['sandbox'] = array();
-    $context['sandbox']['progress'] = 0;
-    $context['sandbox']['current_item'] = 0;
-    $context['results']['entity'] = $item;
-    $context['results']['previous'] = $previous;
-  }
-
-  if (variable_get('cps_override_variables', FALSE)) {
-
-    $result = db_query("SELECT name, value FROM {variable}");
-    $all_variables = array();
-    while ($var = $result->fetchObject()) {
-      $all_variables[$var->name] = unserialize($var->value);
-    }
-
-    foreach ($all_variables as $name => $value) {
-      if (isset($previous->variables[$name]) && $previous->variables[$name] != $value) {
-        db_merge('variable')->key(array('name' => $name))->fields(array('value' => serialize($previous->variables[$name])))->execute();
-      }
-      else {
-        // If a variable is unchanged, unset it so we won't mistakenly report it as a change in the
-        // reverted changeset.
-        if (isset($item->variables[$name])) {
-          unset($item->variables[$name]);
-        }
-      }
-    }
-    cache_clear_all('variables', 'cache_bootstrap');
+function cps_changeset_revert_changeset_form_validate($form, &$form_state) {
+  if (!lock_may_be_available('cps_process')) {
+    form_set_error('revert', t('CPS is processing another changeset, please try again in a few seconds.'));
   }
-
-  $context['message'] = t('Reverting variables');
 }
 
 /**
- * Batch API callback to put in changeset markers for items that exist but didn't change.
- * @param $entity_type
- * @param $context
+ * Submit handler for the revert changeset form.
  */
-function cps_changeset_unpublish_batch_entity(&$context) {
-  $changeset_id = $context['results']['entity']->changeset_id;
-  $previous = $context['results']['previous']->changeset_id;
-  cps_unpublish_changeset_entities($changeset_id, $previous);
-  cps_remove_untracked_entities($changeset_id);
-}
-
-/**
- * Batch API callback to finish the submission.
- *
- * @param $success
- * @param $results
- * @param $operations
- */
-function cps_changeset_unpublish_batch_finished($success, $results, $operations) {
-  if ($success) {
-    // Set the publication time on the item. We have to GET the item somehow.
-    $results['entity']->published = NULL;
-    $results['entity']->setStatus('unpublished');
-    $results['entity']->save();
-
-    drupal_set_message(t('The site version %changeset has been unpublished.', array('%changeset' => $results['entity']->name)));
-  }
-
-  module_invoke_all('cps_changeset_published', $results['entity'], 'unpublished');
-
-  cps_publish_unlock();
+function cps_changeset_revert_changeset_form_submit($form, &$form_state) {
+  $queue = DrupalQueue::get('cps_revert');
+  $queue->createItem(array('changeset_id' => $form_state['entity']->identifier()));
+  $query = array(
+    'cps_revert' => 1,
+    'token' => drupal_get_token($form_state['entity']->identifier()),
+    'changeset_id' => $form_state['entity']->identifier(),
+  );
+  // Reset process notification.
+  cps_notify_progress($form_state['entity']->identifier(), 0, 0);
+  $uri = $form_state['entity']->uri();
+  $form_state['redirect'] = array($uri['path'] . '/status', array('query' => $query));
 }
-
diff --git a/includes/variable.inc b/includes/variable.inc
deleted file mode 100644
index 9feb3b9..0000000
--- a/includes/variable.inc
+++ /dev/null
@@ -1,161 +0,0 @@
-<?php
-
-/**
- * @file
- * variable.inc replacement to override how variables are managed in Drupal.
- *
- * This relies on a patch from https://drupal.org/node/1193396
- */
-
-/**
- * Loads the persistent variable table.
- *
- * The variable table is composed of values that have been saved in the table
- * with variable_set() as well as those explicitly specified in the
- * configuration file.
- */
-function variable_initialize($conf = array()) {
-  // NOTE: caching the variables improves performance by 20% when serving
-  // cached pages.
-  if ($cached = cache_get('variables', 'cache_bootstrap')) {
-    $variables = $cached->data;
-  }
-  else {
-    // Cache miss. Avoid a stampede.
-    $name = 'variable_init';
-    if (!lock_acquire($name, 1)) {
-      // Another request is building the variable cache.
-      // Wait, then re-run this function.
-      lock_wait($name);
-      return variable_initialize($conf);
-    }
-    else {
-      // Proceed with variable rebuild.
-      $variables = array_map('unserialize', db_query('SELECT name, value FROM {variable}')->fetchAllKeyed());
-      cache_set('variables', $variables, 'cache_bootstrap');
-      lock_release($name);
-    }
-  }
-
-  foreach ($conf as $name => $value) {
-    $variables[$name] = $value;
-  }
-
-  return $variables;
-}
-
-/**
- * Returns a persistent variable.
- *
- * Case-sensitivity of the variable_* functions depends on the database
- * collation used. To avoid problems, always use lower case for persistent
- * variable names.
- *
- * @param $name
- *   The name of the variable to return.
- * @param $default
- *   The default value to use if this variable has never been set.
- *
- * @return
- *   The value of the variable. Unserialization is taken care of as necessary.
- *
- * @see variable_del()
- * @see variable_set()
- */
-function _variable_get($name, $default = NULL) {
-  global $conf;
-
-  return isset($conf[$name]) ? $conf[$name] : $default;
-}
-
-/**
- * Sets a persistent variable.
- *
- * Case-sensitivity of the variable_* functions depends on the database
- * collation used. To avoid problems, always use lower case for persistent
- * variable names.
- *
- * @param $name
- *   The name of the variable to set.
- * @param $value
- *   The value to set. This can be any PHP data type; these functions take care
- *   of serialization as necessary.
- *
- * @see variable_del()
- * @see variable_get()
- */
-function variable_set($name, $value) {
-  global $conf;
-  $drupal_change = function () use ($name, $value) {
-    db_merge('variable')
-      ->key(array('name' => $name))
-      ->fields(array('value' => serialize($value)))
-      ->execute();
-  };
-  $cps_change = function () use ($name, $value) {
-    $GLOBALS['cps_conf'][$name] = $value;
-  };
-  variable_changed($name, $drupal_change, $cps_change);
-  $conf[$name] = $value;
-}
-
-/**
- * Unsets a persistent variable.
- *
- * Case-sensitivity of the variable_* functions depends on the database
- * collation used. To avoid problems, always use lower case for persistent
- * variable names.
- *
- * @param $name
- *   The name of the variable to undefine.
- *
- * @see variable_get()
- * @see variable_set()
- */
-function variable_del($name) {
-  global $conf;
-  $drupal_change = function () use ($name) {
-    db_delete('variable')
-      ->condition('name', $name)
-      ->execute();
-  };
-  $cps_change = function () use ($name) {
-    unset($GLOBALS['cps_conf'][$name]);
-  };
-  variable_changed($name, $drupal_change, $cps_change);
-  unset($conf[$name]);
-}
-
-/**
- * Execute a variable change (set or delete).
- *
- * @param string $name
- *   The name of the variable being changed.
- * @param callable $drupal_change
- *   If Drupal handles this variable change then this callable is executed.
- * @param callable $cps_change
- *   If CPS handles this variable change then this callable is executed.
- */
-function variable_changed($name, callable $drupal_change, callable $cps_change) {
-  // The actual writing of these variables happens in hook_exit().
-  // @see cps_exit()
-  // Guarantee the .module file is loaded.
-  drupal_load('module', 'cps');
-  $change_set = cps_get_current_changeset();
-  // Also check a whitelist. Drupal uses a lot of variables for runtime cache
-  // and we shouldn't attach those to changesets.
-  $whitelist = FALSE;
-  if (strpos($name, 'cache') !== FALSE) {
-    $whitelist = TRUE;
-  }
-  if ($change_set == 'published' || $whitelist) {
-    $drupal_change();
-    cache_clear_all('variables', 'cache_bootstrap');
-  }
-  else {
-    $cps_change();
-    // The module's hook_exit will read this and write just the once.
-    global $cps_conf_changed;
-    $cps_conf_changed = TRUE;
-  }
-}
diff --git a/js/process_status.js b/js/process_status.js
new file mode 100644
index 0000000..7f760c0
--- /dev/null
+++ b/js/process_status.js
@@ -0,0 +1,24 @@
+(function ($) {
+  Drupal.behaviors.cpsProcessStatus = {
+    attach: function(context, settings) {
+      // Hide the action links while the changeset is being published, since
+      // they'll do nothing, and also don't reflect the publishing state.
+      $('.action-links').hide();
+      var status_url = settings.cps_process_status_url;
+      Drupal.behaviors.cpsProcessStatus.processJson(status_url);
+    },
+    processJson: function(status_url) {
+      $.getJSON(status_url + '?timestamp=' + Date.now()).done(function(data) {
+        if (data != null) {
+          $('#cps-process-placeholder').html(data.message);
+          if (data.complete !== 1 && data.complete !== -1) {
+            setTimeout(Drupal.behaviors.cpsProcessStatus.processJson(status_url), 1000);
+          }
+        }
+        else {
+          setTimeout(Drupal.behaviors.cpsProcessStatus.processJson(status_url), 1000);
+        }
+      });
+    }
+  };
+})(jQuery);
