Repository: parse-community/Parse-SDK-JS
Pull request #1291 (2021-02-03 08:53:38)

Add EventuallyQueue API
Closes: https://github.com/parse-community/Parse-SDK-JS/issues/1230

This is a similar feature to the [iOS SDK EventuallyQueue](https://github.com/parse-community/Parse-SDK-iOS-OSX/blob/master/Parse/Parse/Internal/PFEventuallyQueue.m) to allow a user to cache objects to be saved later. Inspired by @francimedia [repo](https://github.com/francimedia/parse-js-local-storage).

This has a lot of room for improvement, any feedback is appreciated.

- [ ] Save internal unique hash on object to the DB to ensure operations won't be duplicated.
- [ ] If the operation happens at a later time update object internal state
- [ ] Change Polling setInterval to setTimeout?

### Parse.Object

Saves / Deletes this object to the server at some unspecified time in the future, even if Parse is currently inaccessible.

 Use this when you may not have a solid network connection, and don't need to know when the save completes.
 If there is some problem with the object such that it can't be saved, it will be silently discarded.

 Objects saved / deleted with this method will be stored locally in an on-disk cache until they can be delivered to Parse.
 They will be sent immediately if possible. Otherwise, they will be sent the next time a network connection is
 available. Objects saved this way will persist even after the app is closed, in which case they will be sent the
 next time the app is opened / SDK is initialized.

```
await object.saveEventually(options);
await object.destroyEventually(options);
```

### Advance API

#### Add Object to Queue
Adding unique hashes to your objects is highly recommended

```javascript
const object = new Parse.Object('TestObject');
object.save({ hash: 'unique' });

await Parse.EventuallyQueue.save(object, options);
await Parse.EventuallyQueue.destroy(object, options);
```

#### Send Queue to Parse Server
```javascript
await Parse.EventuallyQueue.sendQueue();
```
You can send the queue either right after adding an object or on connect (or other events).

#### Clear Queue
```javascript
await Parse.EventuallyQueue.clear();
```

#### Polling
This will call sendQueue when a connection to the server is established. Uses the `serverURL` used to initialize sdk
```javascript
Parse.EventuallyQueue.poll();
```


peril-parse-community[bot]: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-Peril;
-->



Danger run resulted in 1 warning; to find out more, see the [checks page](https://github.com/parse-community/Parse-SDK-JS/runs/1821131615).
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a>
</p>

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=h1) Report
> Merging [#1291](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=desc) (34b9021) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/0c84f199207fef94c36e58aa45109ff9b1f7d91d?el=desc) (0c84f19) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291/graphs/tree.svg?width=650&height=150&src=pr&token=8nCENM0QHj)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master     #1291   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           58        59    +1     
  Lines         5650      5749   +99     
  Branches      1273      1291   +18     
=========================================
+ Hits          5650      5749   +99     
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/EventuallyQueue.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291/diff?src=pr&el=tree#diff-c3JjL0V2ZW50dWFsbHlRdWV1ZS5qcw==) | `100.00% <100.00%> (ø)` | |
| [src/Parse.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291/diff?src=pr&el=tree#diff-c3JjL1BhcnNlLmpz) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=footer). Last update [0c84f19...340bcf2](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1291?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

peril-parse-community[bot]: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-Peril;
-->



Danger run resulted in 2 warnings; to find out more, see the [checks page](https://github.com/parse-community/Parse-SDK-JS/runs/1842683002).
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a>
</p>

mtrezza: Looks good, should this be flagged as experimental in the changelog like we do with Parse Server sometimes?
dplewis: Since we can’t turn it off it’s not experimental. 

Patch:
diff --git a/jasmine.json b/jasmine.json
index a7eb69d..1d66e11 100644
--- a/jasmine.json
+++ b/jasmine.json
@@ -7,5 +7,5 @@
     "*Test.js"
   ],
   "random": false,
-  "timeout": 5000
+  "timeout": 10000
 }
diff --git a/src/EventuallyQueue.js b/src/EventuallyQueue.js
new file mode 100644
index 0000000..b84fe9c
--- /dev/null
+++ b/src/EventuallyQueue.js
@@ -0,0 +1,373 @@
+/**
+ * https://github.com/francimedia/parse-js-local-storage
+ *
+ * @flow
+ */
+
+import CoreManager from './CoreManager';
+import ParseObject from './ParseObject';
+import ParseQuery from './ParseQuery';
+import Storage from './Storage';
+
+import type { SaveOptions } from './ParseObject';
+import type { RequestOptions } from './RESTController';
+
+type QueueObject = {
+  queueId: string,
+  action: string,
+  object: ParseObject,
+  serverOptions: SaveOptions | RequestOptions,
+  id: string,
+  className: string,
+  hash: string,
+  createdAt: Date,
+};
+
+type Queue = Array<QueueObject>;
+
+const QUEUE_KEY = 'Parse/Eventually/Queue';
+let queueCache = [];
+let dirtyCache = true;
+let polling = undefined;
+
+/**
+ * Provides utility functions to queue objects that will be
+ * saved to the server at a later date.
+ *
+ * @class Parse.EventuallyQueue
+ * @static
+ */
+const EventuallyQueue = {
+  /**
+   * Add object to queue with save operation.
+   *
+   * @function save
+   * @name Parse.EventuallyQueue.save
+   * @param {ParseObject} object Parse.Object to be saved eventually
+   * @param {object} [serverOptions] See {@link https://parseplatform.org/Parse-SDK-JS/api/master/Parse.Object.html#save Parse.Object.save} options.
+   * @returns {Promise} A promise that is fulfilled if object is added to queue.
+   * @static
+   * @see Parse.Object#saveEventually
+   */
+  save(object: ParseObject, serverOptions: SaveOptions = {}): Promise {
+    return this.enqueue('save', object, serverOptions);
+  },
+
+  /**
+   * Add object to queue with save operation.
+   *
+   * @function destroy
+   * @name Parse.EventuallyQueue.destroy
+   * @param {ParseObject} object Parse.Object to be destroyed eventually
+   * @param {object} [serverOptions] See {@link https://parseplatform.org/Parse-SDK-JS/api/master/Parse.Object.html#destroy Parse.Object.destroy} options
+   * @returns {Promise} A promise that is fulfilled if object is added to queue.
+   * @static
+   * @see Parse.Object#destroyEventually
+   */
+  destroy(object: ParseObject, serverOptions: RequestOptions = {}): Promise {
+    return this.enqueue('destroy', object, serverOptions);
+  },
+
+  /**
+   * Generate unique identifier to avoid duplicates and maintain previous state.
+   *
+   * @param {string} action save / destroy
+   * @param {object} object Parse.Object to be queued
+   * @returns {string}
+   * @static
+   * @ignore
+   */
+  generateQueueId(action: string, object: ParseObject): string {
+    object._getId();
+    const { className, id, _localId } = object;
+    const uniqueId = object.get('hash') || _localId;
+    return [action, className, id, uniqueId].join('_');
+  },
+
+  /**
+   * Build queue object and add to queue.
+   *
+   * @param {string} action save / destroy
+   * @param {object} object Parse.Object to be queued
+   * @param {object} [serverOptions]
+   * @returns {Promise} A promise that is fulfilled if object is added to queue.
+   * @static
+   * @ignore
+   */
+  async enqueue(
+    action: string,
+    object: ParseObject,
+    serverOptions: SaveOptions | RequestOptions
+  ): Promise {
+    const queueData = await this.getQueue();
+    const queueId = this.generateQueueId(action, object);
+
+    let index = this.queueItemExists(queueData, queueId);
+    if (index > -1) {
+      // Add cached values to new object if they don't exist
+      for (const prop in queueData[index].object) {
+        if (typeof object.get(prop) === 'undefined') {
+          object.set(prop, queueData[index].object[prop]);
+        }
+      }
+    } else {
+      index = queueData.length;
+    }
+    queueData[index] = {
+      queueId,
+      action,
+      object: object.toJSON(),
+      serverOptions,
+      id: object.id,
+      className: object.className,
+      hash: object.get('hash'),
+      createdAt: new Date(),
+    };
+    return this.setQueue(queueData);
+  },
+
+  store(data) {
+    return Storage.setItemAsync(QUEUE_KEY, JSON.stringify(data));
+  },
+
+  load() {
+    return Storage.getItemAsync(QUEUE_KEY);
+  },
+
+  /**
+   * Sets the in-memory queue from local storage and returns.
+   *
+   * @function getQueue
+   * @name Parse.EventuallyQueue.getQueue
+   * @returns {Promise<Array>}
+   * @static
+   */
+  async getQueue(): Promise<Array> {
+    if (dirtyCache) {
+      queueCache = JSON.parse((await this.load()) || '[]');
+      dirtyCache = false;
+    }
+    return queueCache;
+  },
+
+  /**
+   * Saves the queue to local storage
+   *
+   * @param {Queue} queue Queue containing Parse.Object data.
+   * @returns {Promise} A promise that is fulfilled when queue is stored.
+   * @static
+   * @ignore
+   */
+  setQueue(queue: Queue): Promise<void> {
+    queueCache = queue;
+    return this.store(queueCache);
+  },
+
+  /**
+   * Removes Parse.Object data from queue.
+   *
+   * @param {string} queueId Unique identifier for Parse.Object data.
+   * @returns {Promise} A promise that is fulfilled when queue is stored.
+   * @static
+   * @ignore
+   */
+  async remove(queueId: string): Promise<void> {
+    const queueData = await this.getQueue();
+    const index = this.queueItemExists(queueData, queueId);
+    if (index > -1) {
+      queueData.splice(index, 1);
+      await this.setQueue(queueData);
+    }
+  },
+
+  /**
+   * Removes all objects from queue.
+   *
+   * @function clear
+   * @name Parse.EventuallyQueue.clear
+   * @returns {Promise} A promise that is fulfilled when queue is cleared.
+   * @static
+   */
+  clear(): Promise {
+    queueCache = [];
+    return this.store([]);
+  },
+
+  /**
+   * Return the index of a queueId in the queue. Returns -1 if not found.
+   *
+   * @param {Queue} queue Queue containing Parse.Object data.
+   * @param {string} queueId Unique identifier for Parse.Object data.
+   * @returns {number}
+   * @static
+   * @ignore
+   */
+  queueItemExists(queue: Queue, queueId: string): number {
+    return queue.findIndex(data => data.queueId === queueId);
+  },
+
+  /**
+   * Return the number of objects in the queue.
+   *
+   * @function length
+   * @name Parse.EventuallyQueue.length
+   * @returns {number}
+   * @static
+   */
+  async length(): number {
+    const queueData = await this.getQueue();
+    return queueData.length;
+  },
+
+  /**
+   * Sends the queue to the server.
+   *
+   * @function sendQueue
+   * @name Parse.EventuallyQueue.sendQueue
+   * @returns {Promise<boolean>} Returns true if queue was sent successfully.
+   * @static
+   */
+  async sendQueue(): Promise<boolean> {
+    const queue = await this.getQueue();
+    const queueData = [...queue];
+
+    if (queueData.length === 0) {
+      return false;
+    }
+    for (let i = 0; i < queueData.length; i += 1) {
+      const queueObject = queueData[i];
+      const { id, hash, className } = queueObject;
+      const ObjectType = ParseObject.extend(className);
+      if (id) {
+        await this.process.byId(ObjectType, queueObject);
+      } else if (hash) {
+        await this.process.byHash(ObjectType, queueObject);
+      } else {
+        await this.process.create(ObjectType, queueObject);
+      }
+    }
+    return true;
+  },
+
+  /**
+   * Build queue object and add to queue.
+   *
+   * @param {ParseObject} object Parse.Object to be processed
+   * @param {QueueObject} queueObject Parse.Object data from the queue
+   * @returns {Promise} A promise that is fulfilled when operation is performed.
+   * @static
+   * @ignore
+   */
+  async sendQueueCallback(object: ParseObject, queueObject: QueueObject): Promise<void> {
+    if (!object) {
+      return this.remove(queueObject.queueId);
+    }
+    switch (queueObject.action) {
+    case 'save':
+      // Queued update was overwritten by other request. Do not save
+      if (
+        typeof object.updatedAt !== 'undefined' &&
+          object.updatedAt > new Date(queueObject.object.createdAt)
+      ) {
+        return this.remove(queueObject.queueId);
+      }
+      try {
+        await object.save(queueObject.object, queueObject.serverOptions);
+        await this.remove(queueObject.queueId);
+      } catch (e) {
+        if (e.message !== 'XMLHttpRequest failed: "Unable to connect to the Parse API"') {
+          await this.remove(queueObject.queueId);
+        }
+      }
+      break;
+    case 'destroy':
+      try {
+        await object.destroy(queueObject.serverOptions);
+        await this.remove(queueObject.queueId);
+      } catch (e) {
+        if (e.message !== 'XMLHttpRequest failed: "Unable to connect to the Parse API"') {
+          await this.remove(queueObject.queueId);
+        }
+      }
+      break;
+    }
+  },
+
+  /**
+   * Start polling server for network connection.
+   * Will send queue if connection is established.
+   *
+   * @function poll
+   * @name Parse.EventuallyQueue.poll
+   * @param [ms] Milliseconds to ping the server. Default 2000ms
+   * @static
+   */
+  poll(ms: number = 2000) {
+    if (polling) {
+      return;
+    }
+    polling = setInterval(() => {
+      const RESTController = CoreManager.getRESTController();
+      RESTController.ajax('GET', CoreManager.get('SERVER_URL')).catch(error => {
+        if (error !== 'Unable to connect to the Parse API') {
+          this.stopPoll();
+          return this.sendQueue();
+        }
+      });
+    }, ms);
+  },
+
+  /**
+   * Turns off polling.
+   *
+   * @function stopPoll
+   * @name Parse.EventuallyQueue.stopPoll
+   * @static
+   */
+  stopPoll() {
+    clearInterval(polling);
+    polling = undefined;
+  },
+
+  /**
+   * Return true if pinging the server.
+   *
+   * @function isPolling
+   * @name Parse.EventuallyQueue.isPolling
+   * @returns {boolean}
+   * @static
+   */
+  isPolling(): boolean {
+    return !!polling;
+  },
+
+  _setPolling(flag: boolean) {
+    polling = flag;
+  },
+
+  process: {
+    create(ObjectType, queueObject) {
+      const object = new ObjectType();
+      return EventuallyQueue.sendQueueCallback(object, queueObject);
+    },
+    async byId(ObjectType, queueObject) {
+      const { sessionToken } = queueObject.serverOptions;
+      const query = new ParseQuery(ObjectType);
+      query.equalTo('objectId', queueObject.id);
+      const results = await query.find({ sessionToken });
+      return EventuallyQueue.sendQueueCallback(results[0], queueObject);
+    },
+    async byHash(ObjectType, queueObject) {
+      const { sessionToken } = queueObject.serverOptions;
+      const query = new ParseQuery(ObjectType);
+      query.equalTo('hash', queueObject.hash);
+      const results = await query.find({ sessionToken });
+      if (results.length > 0) {
+        return EventuallyQueue.sendQueueCallback(results[0], queueObject);
+      }
+      return EventuallyQueue.process.create(ObjectType, queueObject);
+    },
+  },
+};
+
+module.exports = EventuallyQueue;
diff --git a/src/Parse.js b/src/Parse.js
index aeef1f2..e132b27 100644
--- a/src/Parse.js
+++ b/src/Parse.js
@@ -11,6 +11,7 @@ import decode from './decode';
 import encode from './encode';
 import CoreManager from './CoreManager';
 import CryptoController from './CryptoController';
+import EventuallyQueue from './EventuallyQueue';
 import InstallationController from './InstallationController';
 import * as ParseOp from './ParseOp';
 import RESTController from './RESTController';
@@ -46,6 +47,7 @@ const Parse = {
       /* eslint-enable no-console */
     }
     Parse._initialize(applicationId, javaScriptKey);
+    EventuallyQueue.poll();
   },
 
   _initialize(applicationId: string, javaScriptKey: string, masterKey: string) {
@@ -197,6 +199,7 @@ Parse.CLP = require('./ParseCLP').default;
 Parse.CoreManager = require('./CoreManager');
 Parse.Config = require('./ParseConfig').default;
 Parse.Error = require('./ParseError').default;
+Parse.EventuallyQueue = EventuallyQueue;
 Parse.FacebookUtils = require('./FacebookUtils').default;
 Parse.File = require('./ParseFile').default;
 Parse.GeoPoint = require('./ParseGeoPoint').default;
diff --git a/src/ParseObject.js b/src/ParseObject.js
index 1efb310..312428d 100644
--- a/src/ParseObject.js
+++ b/src/ParseObject.js
@@ -14,6 +14,7 @@ import canBeSerialized from './canBeSerialized';
 import decode from './decode';
 import encode from './encode';
 import escape from './escape';
+import EventuallyQueue from './EventuallyQueue';
 import ParseACL from './ParseACL';
 import parseDate from './parseDate';
 import ParseError from './ParseError';
@@ -56,7 +57,7 @@ type SaveParams = {
   body: AttributeMap,
 };
 
-type SaveOptions = FullOptions & {
+export type SaveOptions = FullOptions & {
   cascadeSave?: boolean,
   context?: AttributeMap,
 };
@@ -1200,6 +1201,42 @@ class ParseObject {
     return this.fetch(options);
   }
 
+  /**
+   * Saves this object to the server at some unspecified time in the future,
+   * even if Parse is currently inaccessible.
+   *
+   * Use this when you may not have a solid network connection, and don't need to know when the save completes.
+   * If there is some problem with the object such that it can't be saved, it will be silently discarded.
+   *
+   * Objects saved with this method will be stored locally in an on-disk cache until they can be delivered to Parse.
+   * They will be sent immediately if possible. Otherwise, they will be sent the next time a network connection is
+   * available. Objects saved this way will persist even after the app is closed, in which case they will be sent the
+   * next time the app is opened.
+   *
+   * @param {object} [options]
+   * Used to pass option parameters to method if arg1 and arg2 were both passed as strings.
+   * Valid options are:
+   * <ul>
+   * <li>sessionToken: A valid session token, used for making a request on
+   * behalf of a specific user.
+   * <li>cascadeSave: If `false`, nested objects will not be saved (default is `true`).
+   * <li>context: A dictionary that is accessible in Cloud Code `beforeSave` and `afterSave` triggers.
+   * </ul>
+   * @returns {Promise} A promise that is fulfilled when the save
+   * completes.
+   */
+  async saveEventually(options: SaveOptions): Promise {
+    try {
+      await this.save(null, options);
+    } catch (e) {
+      if (e.message === 'XMLHttpRequest failed: "Unable to connect to the Parse API"') {
+        await EventuallyQueue.save(this, options);
+        EventuallyQueue.poll();
+      }
+    }
+    return this;
+  }
+
   /**
    * Set a hash of model attributes, and save the model to the server.
    * updatedAt will be updated when the request returns.
@@ -1305,6 +1342,40 @@ class ParseObject {
     });
   }
 
+  /**
+   * Deletes this object from the server at some unspecified time in the future,
+   * even if Parse is currently inaccessible.
+   *
+   * Use this when you may not have a solid network connection,
+   * and don't need to know when the delete completes. If there is some problem with the object
+   * such that it can't be deleted, the request will be silently discarded.
+   *
+   * Delete instructions made with this method will be stored locally in an on-disk cache until they can be transmitted
+   * to Parse. They will be sent immediately if possible. Otherwise, they will be sent the next time a network connection
+   * is available. Delete requests will persist even after the app is closed, in which case they will be sent the
+   * next time the app is opened.
+   *
+   * @param {object} [options]
+   * Valid options are:<ul>
+   *   <li>sessionToken: A valid session token, used for making a request on
+   *       behalf of a specific user.
+   *   <li>context: A dictionary that is accessible in Cloud Code `beforeDelete` and `afterDelete` triggers.
+   * </ul>
+   * @returns {Promise} A promise that is fulfilled when the destroy
+   *     completes.
+   */
+  async destroyEventually(options: RequestOptions): Promise {
+    try {
+      await this.destroy(options);
+    } catch (e) {
+      if (e.message === 'XMLHttpRequest failed: "Unable to connect to the Parse API"') {
+        await EventuallyQueue.destroy(this, options);
+        EventuallyQueue.poll();
+      }
+    }
+    return this;
+  }
+
   /**
    * Destroy this model on the server if it was already persisted.
    *


Test patch:
diff --git a/integration/test/ParseEventuallyQueueTest.js b/integration/test/ParseEventuallyQueueTest.js
new file mode 100644
index 0000000..ce718b9
--- /dev/null
+++ b/integration/test/ParseEventuallyQueueTest.js
@@ -0,0 +1,238 @@
+'use strict';
+
+const assert = require('assert');
+const Parse = require('../../node');
+const sleep = require('./sleep');
+
+describe('Parse EventuallyQueue', () => {
+  beforeEach(async () => {
+    await Parse.EventuallyQueue.clear();
+  });
+
+  it('can queue save object', async () => {
+    const object = new TestObject({ test: 'test' });
+    await object.save();
+    object.set('foo', 'bar');
+    await Parse.EventuallyQueue.save(object);
+    await Parse.EventuallyQueue.sendQueue();
+
+    const query = new Parse.Query(TestObject);
+    const result = await query.get(object.id);
+    assert.strictEqual(result.get('foo'), 'bar');
+
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+  });
+
+  it('can queue destroy object', async () => {
+    const object = new TestObject({ test: 'test' });
+    await object.save();
+    await Parse.EventuallyQueue.destroy(object);
+    await Parse.EventuallyQueue.sendQueue();
+
+    const query = new Parse.Query(TestObject);
+    query.equalTo('objectId', object.id);
+    const results = await query.find();
+    assert.strictEqual(results.length, 0);
+
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+  });
+
+  it('can queue multiple object', async () => {
+    const obj1 = new TestObject({ foo: 'bar' });
+    const obj2 = new TestObject({ foo: 'baz' });
+    const obj3 = new TestObject({ foo: 'bag' });
+    await Parse.EventuallyQueue.save(obj1);
+    await Parse.EventuallyQueue.save(obj2);
+    await Parse.EventuallyQueue.save(obj3);
+
+    let length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 3);
+
+    await Parse.EventuallyQueue.sendQueue();
+
+    const query = new Parse.Query(TestObject);
+    query.ascending('createdAt');
+    const results = await query.find();
+    assert.strictEqual(results.length, 3);
+    assert.strictEqual(results[0].get('foo'), 'bar');
+    assert.strictEqual(results[1].get('foo'), 'baz');
+    assert.strictEqual(results[2].get('foo'), 'bag');
+
+    length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+
+    // TODO: Properly handle SingleInstance
+    await Parse.EventuallyQueue.destroy(results[0]);
+    await Parse.EventuallyQueue.destroy(results[1]);
+    await Parse.EventuallyQueue.destroy(results[2]);
+
+    length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 3);
+
+    await Parse.EventuallyQueue.sendQueue();
+    const objects = await query.find();
+    assert.strictEqual(objects.length, 0);
+  });
+
+  it('can queue destroy for object that does not exist', async () => {
+    const object = new TestObject({ test: 'test' });
+    await object.save();
+    await object.destroy();
+    await Parse.EventuallyQueue.destroy(object);
+    await Parse.EventuallyQueue.sendQueue();
+
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+  });
+
+  it('can queue destroy then save', async () => {
+    const object = new TestObject({ hash: 'test' });
+    await Parse.EventuallyQueue.destroy(object);
+    await Parse.EventuallyQueue.save(object);
+    await Parse.EventuallyQueue.sendQueue();
+
+    const query = new Parse.Query(TestObject);
+    query.equalTo('hash', 'test');
+    const results = await query.find();
+    assert.strictEqual(results.length, 1);
+
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+  });
+
+  it('can queue unsaved object with hash', async () => {
+    const hash = 'secret';
+    const object = new TestObject({ test: 'test' });
+    object.set('hash', hash);
+    await Parse.EventuallyQueue.save(object);
+    await Parse.EventuallyQueue.sendQueue();
+
+    const query = new Parse.Query(TestObject);
+    query.equalTo('hash', hash);
+    const results = await query.find();
+    assert.strictEqual(results.length, 1);
+  });
+
+  it('can queue saved object and unsaved with hash', async () => {
+    const hash = 'ransom+salt';
+    const object = new TestObject({ test: 'test' });
+    object.set('hash', hash);
+    await Parse.EventuallyQueue.save(object);
+    await Parse.EventuallyQueue.sendQueue();
+
+    let query = new Parse.Query(TestObject);
+    query.equalTo('hash', hash);
+    const results = await query.find();
+    assert.strictEqual(results.length, 1);
+
+    const unsaved = new TestObject({ hash, foo: 'bar' });
+    await Parse.EventuallyQueue.save(unsaved);
+    await Parse.EventuallyQueue.sendQueue();
+
+    query = new Parse.Query(TestObject);
+    query.equalTo('hash', hash);
+    const hashes = await query.find();
+    assert.strictEqual(hashes.length, 1);
+    assert.strictEqual(hashes[0].get('foo'), 'bar');
+  });
+
+  it('can queue same object but override undefined fields', async () => {
+    const object = new Parse.Object('TestObject');
+    object.set('foo', 'bar');
+    object.set('test', '1234');
+    await Parse.EventuallyQueue.save(object);
+
+    object.set('foo', undefined);
+    await Parse.EventuallyQueue.save(object);
+
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 1);
+
+    const queue = await Parse.EventuallyQueue.getQueue();
+    assert.strictEqual(queue[0].object.foo, 'bar');
+    assert.strictEqual(queue[0].object.test, '1234');
+  });
+
+  it('can poll server', async () => {
+    const object = new TestObject({ test: 'test' });
+    await object.save();
+    object.set('foo', 'bar');
+    await Parse.EventuallyQueue.save(object);
+    Parse.EventuallyQueue.poll();
+    assert.ok(Parse.EventuallyQueue.isPolling());
+
+    await sleep(4000);
+    const query = new Parse.Query(TestObject);
+    const result = await query.get(object.id);
+    assert.strictEqual(result.get('foo'), 'bar');
+
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+    assert.strictEqual(Parse.EventuallyQueue.isPolling(), false);
+  });
+
+  it('can clear queue', async () => {
+    const object = new TestObject({ test: 'test' });
+    await object.save();
+    await Parse.EventuallyQueue.save(object);
+    const q = await Parse.EventuallyQueue.getQueue();
+    assert.strictEqual(q.length, 1);
+
+    await Parse.EventuallyQueue.clear();
+    const length = await Parse.EventuallyQueue.length();
+    assert.strictEqual(length, 0);
+  });
+
+  it('can saveEventually', async done => {
+    const parseServer = await reconfigureServer();
+    const object = new TestObject({ hash: 'saveSecret' });
+    await parseServer.handleShutdown();
+    parseServer.server.close(async () => {
+      await object.saveEventually();
+      let length = await Parse.EventuallyQueue.length();
+      assert(Parse.EventuallyQueue.isPolling());
+      assert.strictEqual(length, 1);
+
+      await reconfigureServer({});
+      await sleep(3000); // Wait for polling
+
+      assert.strictEqual(Parse.EventuallyQueue.isPolling(), false);
+      length = await Parse.EventuallyQueue.length();
+      assert.strictEqual(length, 0);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('hash', 'saveSecret');
+      const results = await query.find();
+      assert.strictEqual(results.length, 1);
+      done();
+    });
+  });
+
+  it('can destroyEventually', async done => {
+    const parseServer = await reconfigureServer();
+    const object = new TestObject({ hash: 'deleteSecret' });
+    await object.save();
+    await parseServer.handleShutdown();
+    parseServer.server.close(async () => {
+      await object.destroyEventually();
+      let length = await Parse.EventuallyQueue.length();
+      assert(Parse.EventuallyQueue.isPolling());
+      assert.strictEqual(length, 1);
+
+      await reconfigureServer({});
+      await sleep(3000); // Wait for polling
+
+      assert.strictEqual(Parse.EventuallyQueue.isPolling(), false);
+      length = await Parse.EventuallyQueue.length();
+      assert.strictEqual(length, 0);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('hash', 'deleteSecret');
+      const results = await query.find();
+      assert.strictEqual(results.length, 0);
+      done();
+    });
+  });
+});
diff --git a/integration/test/helper.js b/integration/test/helper.js
index f6739c7..87786d8 100644
--- a/integration/test/helper.js
+++ b/integration/test/helper.js
@@ -1,4 +1,4 @@
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000;
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
 
 const ParseServer = require('parse-server').default;
 const CustomAuth = require('./CustomAuth');
diff --git a/src/__tests__/EventuallyQueue-test.js b/src/__tests__/EventuallyQueue-test.js
new file mode 100644
index 0000000..c5460d2
--- /dev/null
+++ b/src/__tests__/EventuallyQueue-test.js
@@ -0,0 +1,427 @@
+jest.autoMockOff();
+jest.useFakeTimers();
+
+let objectCount = 0;
+
+class MockObject {
+  constructor(className) {
+    this.className = className;
+    this.attributes = {};
+
+    this.id = String(objectCount++);
+    this._localId = `local${objectCount}`;
+  }
+  destroy() {}
+  save() {}
+  _getId() {
+    return this.id || this._localId;
+  }
+  set(key, value) {
+    this.attributes[key] = value;
+  }
+  get(key) {
+    return this.attributes[key];
+  }
+  toJSON() {
+    return this.attributes;
+  }
+  static extend(className) {
+    class MockSubclass {
+      constructor() {
+        this.className = className;
+      }
+    }
+    return MockSubclass;
+  }
+}
+jest.setMock('../ParseObject', MockObject);
+
+const mockQueryFind = jest.fn();
+jest.mock('../ParseQuery', () => {
+  return jest.fn().mockImplementation(function () {
+    this.equalTo = jest.fn();
+    this.find = mockQueryFind;
+  });
+});
+const mockRNStorageInterface = require('./test_helpers/mockRNStorage');
+const CoreManager = require('../CoreManager');
+const EventuallyQueue = require('../EventuallyQueue');
+const ParseError = require('../ParseError').default;
+const ParseObject = require('../ParseObject');
+const RESTController = require('../RESTController');
+const Storage = require('../Storage');
+const mockXHR = require('./test_helpers/mockXHR');
+
+function flushPromises() {
+  return new Promise(resolve => setImmediate(resolve));
+}
+
+describe('EventuallyQueue', () => {
+  beforeEach(async () => {
+    jest.clearAllMocks();
+    CoreManager.setAsyncStorage(mockRNStorageInterface);
+    CoreManager.setStorageController(require('../StorageController.react-native'));
+    CoreManager.setRESTController(RESTController);
+    EventuallyQueue.stopPoll();
+    await EventuallyQueue.clear();
+  });
+
+  it('init empty', async () => {
+    const queue = await EventuallyQueue.getQueue();
+    const length = await EventuallyQueue.length();
+    expect(queue.length).toBe(length);
+  });
+
+  it('can get invalid storage', async () => {
+    jest.spyOn(Storage, 'getItemAsync').mockImplementationOnce(() => {
+      return Promise.resolve(undefined);
+    });
+    const queue = await EventuallyQueue.getQueue();
+    expect(queue.length).toBe(0);
+    expect(queue).toEqual([]);
+  });
+
+  it('can queue object', async () => {
+    const object = new ParseObject('TestObject');
+    await EventuallyQueue.save(object, { sessionToken: 'token' });
+    let length = await EventuallyQueue.length();
+    expect(length).toBe(1);
+
+    await EventuallyQueue.destroy(object, { sessionToken: 'secret' });
+    length = await EventuallyQueue.length();
+    expect(length).toBe(2);
+
+    const [savedObject, deleteObject] = await EventuallyQueue.getQueue();
+    expect(savedObject.id).toEqual(object.id);
+    expect(savedObject.action).toEqual('save');
+    expect(savedObject.object).toEqual(object.toJSON());
+    expect(savedObject.className).toEqual(object.className);
+    expect(savedObject.serverOptions).toEqual({ sessionToken: 'token' });
+    expect(savedObject.createdAt).toBeDefined();
+    expect(savedObject.queueId).toEqual(EventuallyQueue.generateQueueId('save', object));
+
+    expect(deleteObject.id).toEqual(object.id);
+    expect(deleteObject.action).toEqual('destroy');
+    expect(deleteObject.object).toEqual(object.toJSON());
+    expect(deleteObject.className).toEqual(object.className);
+    expect(deleteObject.serverOptions).toEqual({ sessionToken: 'secret' });
+    expect(deleteObject.createdAt).toBeDefined();
+    expect(deleteObject.queueId).toEqual(EventuallyQueue.generateQueueId('destroy', object));
+  });
+
+  it('can queue same save object', async () => {
+    const object = new ParseObject('TestObject');
+    await EventuallyQueue.save(object);
+    await EventuallyQueue.save(object);
+    const length = await EventuallyQueue.length();
+    expect(length).toBe(1);
+  });
+
+  it('can queue same destroy object', async () => {
+    const object = new ParseObject('TestObject');
+    await EventuallyQueue.destroy(object);
+    await EventuallyQueue.destroy(object);
+    const length = await EventuallyQueue.length();
+    expect(length).toBe(1);
+  });
+
+  it('can queue same object but override undefined fields', async () => {
+    const object = new ParseObject('TestObject');
+    object.set('foo', 'bar');
+    object.set('test', '1234');
+    await EventuallyQueue.save(object);
+
+    object.set('foo', undefined);
+    await EventuallyQueue.save(object);
+
+    const length = await EventuallyQueue.length();
+    expect(length).toBe(1);
+
+    const queue = await EventuallyQueue.getQueue();
+    expect(queue[0].object.test).toBe('1234');
+  });
+
+  it('can remove object from queue', async () => {
+    const object = new ParseObject('TestObject');
+    await EventuallyQueue.save(object);
+    let length = await EventuallyQueue.length();
+    expect(length).toBe(1);
+
+    const queue = await EventuallyQueue.getQueue();
+    const { queueId } = queue[0];
+    await EventuallyQueue.remove(queueId);
+
+    length = await EventuallyQueue.length();
+    expect(length).toBe(0);
+    await EventuallyQueue.remove(queueId);
+
+    length = await EventuallyQueue.length();
+    expect(length).toBe(0);
+  });
+
+  it('can send empty queue to server', async () => {
+    const length = await EventuallyQueue.length();
+    expect(length).toBe(0);
+    const didSend = await EventuallyQueue.sendQueue();
+    expect(didSend).toBe(false);
+  });
+
+  it('can send queue by object id', async () => {
+    jest.spyOn(EventuallyQueue.process, 'byId').mockImplementationOnce(() => {});
+    const object = new ParseObject('TestObject');
+    await EventuallyQueue.save(object);
+
+    const didSend = await EventuallyQueue.sendQueue();
+    expect(didSend).toBe(true);
+    expect(EventuallyQueue.process.byId).toHaveBeenCalledTimes(1);
+  });
+
+  it('can send queue by object hash', async () => {
+    jest.spyOn(EventuallyQueue.process, 'byHash').mockImplementationOnce(() => {});
+    const object = new ParseObject('TestObject');
+    delete object.id;
+    object.set('hash', 'secret');
+    await EventuallyQueue.save(object);
+
+    const didSend = await EventuallyQueue.sendQueue();
+    expect(didSend).toBe(true);
+    expect(EventuallyQueue.process.byHash).toHaveBeenCalledTimes(1);
+  });
+
+  it('can send queue by object create', async () => {
+    jest.spyOn(EventuallyQueue.process, 'create').mockImplementationOnce(() => {});
+    const object = new ParseObject('TestObject');
+    delete object.id;
+    await EventuallyQueue.save(object);
+
+    const didSend = await EventuallyQueue.sendQueue();
+    expect(didSend).toBe(true);
+    expect(EventuallyQueue.process.create).toHaveBeenCalledTimes(1);
+  });
+
+  it('can handle send queue destroy callback', async () => {
+    const object = new ParseObject('TestObject');
+    jest.spyOn(object, 'destroy').mockImplementationOnce(() => {});
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    const queueObject = {
+      action: 'destroy',
+      queueId: 'queue1',
+      serverOptions: { sessionToken: 'token' },
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(object.destroy).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1);
+    expect(object.destroy).toHaveBeenCalledWith({ sessionToken: 'token' });
+    expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue1');
+  });
+
+  it('should remove if queue destroy callback fails', async () => {
+    const object = new ParseObject('TestObject');
+    jest.spyOn(object, 'destroy').mockImplementationOnce(() => {
+      return Promise.reject('Unable to delete object.');
+    });
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    const queueObject = {
+      action: 'destroy',
+      queueId: 'queue1',
+      serverOptions: {},
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(object.destroy).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1);
+    expect(object.destroy).toHaveBeenCalledWith({});
+    expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue1');
+  });
+
+  it('should not remove if queue destroy callback network fails', async () => {
+    const object = new ParseObject('TestObject');
+    jest.spyOn(object, 'destroy').mockImplementationOnce(() => {
+      throw new ParseError(
+        ParseError.CONNECTION_FAILED,
+        'XMLHttpRequest failed: "Unable to connect to the Parse API"'
+      );
+    });
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    const queueObject = {
+      action: 'destroy',
+      queueId: 'queue1',
+      serverOptions: {},
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(object.destroy).toHaveBeenCalledTimes(1);
+    expect(object.destroy).toHaveBeenCalledWith({});
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(0);
+  });
+
+  it('can handle send queue save callback with no object', async () => {
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    const object = null;
+
+    const queueObject = { queueId: 'queue0' };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue0');
+  });
+
+  it('can handle send queue save callback', async () => {
+    const object = new ParseObject('TestObject');
+    jest.spyOn(object, 'save').mockImplementationOnce(() => {});
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    await EventuallyQueue.save(object);
+
+    const queueObject = {
+      action: 'save',
+      queueId: 'queue2',
+      object: { foo: 'bar' },
+      serverOptions: { sessionToken: 'token' },
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(object.save).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1);
+    expect(object.save).toHaveBeenCalledWith({ foo: 'bar' }, { sessionToken: 'token' });
+    expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue2');
+  });
+
+  it('can handle send queue save callback', async () => {
+    const object = new ParseObject('TestObject');
+    jest.spyOn(object, 'save').mockImplementationOnce(() => {
+      return Promise.reject('Unable to save.');
+    });
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    await EventuallyQueue.save(object);
+
+    const queueObject = {
+      action: 'save',
+      queueId: 'queue2',
+      object: { foo: 'bar' },
+      serverOptions: { sessionToken: 'token' },
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(object.save).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1);
+    expect(object.save).toHaveBeenCalledWith({ foo: 'bar' }, { sessionToken: 'token' });
+    expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue2');
+  });
+
+  it('should not remove if queue save callback network fails', async () => {
+    const object = new ParseObject('TestObject');
+    jest.spyOn(object, 'save').mockImplementationOnce(() => {
+      throw new ParseError(
+        ParseError.CONNECTION_FAILED,
+        'XMLHttpRequest failed: "Unable to connect to the Parse API"'
+      );
+    });
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    const queueObject = {
+      action: 'save',
+      queueId: 'queue2',
+      object: { foo: 'bar' },
+      serverOptions: {},
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(object.save).toHaveBeenCalledTimes(1);
+    expect(object.save).toHaveBeenCalledWith({ foo: 'bar' }, {});
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(0);
+  });
+
+  it('can handle send queue save callback if queue is old', async () => {
+    jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {});
+    const today = new Date();
+    const tomorrow = new Date(today);
+    tomorrow.setDate(tomorrow.getDate() + 1);
+
+    const object = new ParseObject('TestObject');
+    object.updatedAt = tomorrow;
+    await EventuallyQueue.save(object);
+
+    const queueObject = {
+      action: 'save',
+      queueId: 'queue3',
+      object: { createdAt: new Date() },
+    };
+    await EventuallyQueue.sendQueueCallback(object, queueObject);
+    expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue3');
+  });
+
+  it('can process new object', async () => {
+    jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {});
+    await EventuallyQueue.process.create(MockObject, {});
+    expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1);
+  });
+
+  it('can process object by id', async () => {
+    jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {});
+    const object = new ParseObject('TestObject');
+    const queueObject = {
+      id: 'object1',
+      serverOptions: { sessionToken: 'idToken' },
+    };
+    mockQueryFind.mockImplementationOnce(() => Promise.resolve([object]));
+    await EventuallyQueue.process.byId(MockObject, queueObject);
+    expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject);
+    expect(mockQueryFind).toHaveBeenCalledTimes(1);
+    expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'idToken' });
+  });
+
+  it('can process object by hash', async () => {
+    jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {});
+    const object = new ParseObject('TestObject');
+    const queueObject = {
+      hash: 'secret',
+      serverOptions: { sessionToken: 'hashToken' },
+    };
+    mockQueryFind.mockImplementationOnce(() => Promise.resolve([object]));
+    await EventuallyQueue.process.byHash(MockObject, queueObject);
+    expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject);
+    expect(mockQueryFind).toHaveBeenCalledTimes(1);
+    expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' });
+  });
+
+  it('can process new object if hash not exists', async () => {
+    jest.spyOn(EventuallyQueue.process, 'create').mockImplementationOnce(() => {});
+    const queueObject = {
+      hash: 'secret',
+      serverOptions: { sessionToken: 'hashToken' },
+    };
+    mockQueryFind.mockImplementationOnce(() => Promise.resolve([]));
+    await EventuallyQueue.process.byHash(MockObject, queueObject);
+    expect(mockQueryFind).toHaveBeenCalledTimes(1);
+    expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' });
+  });
+
+  it('cannot poll if already polling', () => {
+    EventuallyQueue._setPolling(true);
+    EventuallyQueue.poll();
+    expect(EventuallyQueue.isPolling()).toBe(true);
+  });
+
+  it('can poll server', async () => {
+    jest.spyOn(EventuallyQueue, 'sendQueue').mockImplementationOnce(() => {});
+    RESTController._setXHR(mockXHR([{ status: 107, response: { error: 'ok' } }]));
+    EventuallyQueue.poll();
+    expect(EventuallyQueue.isPolling()).toBe(true);
+    jest.runOnlyPendingTimers();
+    await flushPromises();
+
+    expect(EventuallyQueue.isPolling()).toBe(false);
+    expect(EventuallyQueue.sendQueue).toHaveBeenCalledTimes(1);
+  });
+
+  it('can continue polling with connection error', async () => {
+    const retry = CoreManager.get('REQUEST_ATTEMPT_LIMIT');
+    CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1);
+    RESTController._setXHR(
+      mockXHR([{ status: 0 }, { status: 0 }, { status: 0 }, { status: 0 }, { status: 0 }])
+    );
+    EventuallyQueue.poll();
+    expect(EventuallyQueue.isPolling()).toBe(true);
+    jest.runOnlyPendingTimers();
+    await flushPromises();
+
+    expect(EventuallyQueue.isPolling()).toBe(true);
+    CoreManager.set('REQUEST_ATTEMPT_LIMIT', retry);
+  });
+});
diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js
index d78d53a..c38c956 100644
--- a/src/__tests__/Parse-test.js
+++ b/src/__tests__/Parse-test.js
@@ -14,8 +14,10 @@ jest.dontMock('../encode');
 jest.dontMock('../Parse');
 jest.dontMock('../LocalDatastore');
 jest.dontMock('crypto-js/aes');
+jest.setMock('../EventuallyQueue', { poll: jest.fn() });
 
 const CoreManager = require('../CoreManager');
+const EventuallyQueue = require('../EventuallyQueue');
 const Parse = require('../Parse');
 
 describe('Parse module', () => {
@@ -36,6 +38,12 @@ describe('Parse module', () => {
     expect(CoreManager.get('USE_MASTER_KEY')).toBe(true);
   });
 
+  it('should not start eventually queue poll in node build', () => {
+    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {});
+    Parse.initialize('A', 'B');
+    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0);
+  });
+
   it('exposes certain keys as properties', () => {
     Parse.applicationId = '123';
     expect(CoreManager.get('APPLICATION_ID')).toBe('123');
diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js
index 892e6fb..3a2ddba 100644
--- a/src/__tests__/ParseObject-test.js
+++ b/src/__tests__/ParseObject-test.js
@@ -140,6 +140,7 @@ const mockLocalDatastore = {
 jest.setMock('../LocalDatastore', mockLocalDatastore);
 
 const CoreManager = require('../CoreManager');
+const EventuallyQueue = require('../EventuallyQueue');
 const ParseACL = require('../ParseACL').default;
 const ParseError = require('../ParseError').default;
 const ParseFile = require('../ParseFile').default;
@@ -1533,6 +1534,53 @@ describe('ParseObject', () => {
     });
   });
 
+  it('can save the object eventually', async () => {
+    CoreManager.getRESTController()._setXHR(
+      mockXHR([
+        {
+          status: 200,
+          response: {
+            objectId: 'PFEventually',
+          },
+        },
+      ])
+    );
+    const p = new ParseObject('Person');
+    p.set('age', 38);
+    const obj = await p.saveEventually();
+    expect(obj).toBe(p);
+    expect(obj.get('age')).toBe(38);
+    expect(obj.op('age')).toBe(undefined);
+    expect(obj.dirty()).toBe(false);
+  });
+
+  it('can save the object eventually on network failure', async () => {
+    const p = new ParseObject('Person');
+    jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve());
+    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {});
+    jest.spyOn(p, 'save').mockImplementationOnce(() => {
+      throw new ParseError(
+        ParseError.CONNECTION_FAILED,
+        'XMLHttpRequest failed: "Unable to connect to the Parse API"'
+      );
+    });
+    await p.saveEventually();
+    expect(EventuallyQueue.save).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1);
+  });
+
+  it('should not save the object eventually on error', async () => {
+    const p = new ParseObject('Person');
+    jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve());
+    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {});
+    jest.spyOn(p, 'save').mockImplementationOnce(() => {
+      throw new ParseError(ParseError.OTHER_CAUSE, 'Tried to save a batch with a cycle.');
+    });
+    await p.saveEventually();
+    expect(EventuallyQueue.save).toHaveBeenCalledTimes(0);
+    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0);
+  });
+
   it('can save the object with key / value', done => {
     CoreManager.getRESTController()._setXHR(
       mockXHR([
@@ -2844,6 +2892,33 @@ describe('ObjectController', () => {
     await result;
   });
 
+  it('can destroy the object eventually on network failure', async () => {
+    const p = new ParseObject('Person');
+    jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve());
+    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {});
+    jest.spyOn(p, 'destroy').mockImplementationOnce(() => {
+      throw new ParseError(
+        ParseError.CONNECTION_FAILED,
+        'XMLHttpRequest failed: "Unable to connect to the Parse API"'
+      );
+    });
+    await p.destroyEventually();
+    expect(EventuallyQueue.destroy).toHaveBeenCalledTimes(1);
+    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1);
+  });
+
+  it('should not destroy object eventually on error', async () => {
+    const p = new ParseObject('Person');
+    jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve());
+    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {});
+    jest.spyOn(p, 'destroy').mockImplementationOnce(() => {
+      throw new ParseError(ParseError.OTHER_CAUSE, 'Unable to delete.');
+    });
+    await p.destroyEventually();
+    expect(EventuallyQueue.destroy).toHaveBeenCalledTimes(0);
+    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0);
+  });
+
   it('can save an object', async () => {
     const objectController = CoreManager.getObjectController();
     const xhr = {
diff --git a/src/__tests__/browser-test.js b/src/__tests__/browser-test.js
index 3b77c4d..7e1682c 100644
--- a/src/__tests__/browser-test.js
+++ b/src/__tests__/browser-test.js
@@ -8,8 +8,10 @@ jest.dontMock('../Parse');
 jest.dontMock('../RESTController');
 jest.dontMock('../Storage');
 jest.dontMock('crypto-js/aes');
+jest.setMock('../EventuallyQueue', { poll: jest.fn() });
 
 const ParseError = require('../ParseError').default;
+const EventuallyQueue = require('../EventuallyQueue');
 
 class XMLHttpRequest {}
 class XDomainRequest {
@@ -50,6 +52,14 @@ describe('Browser', () => {
     expect(Parse._initialize).toHaveBeenCalledTimes(1);
   });
 
+  it('should start eventually queue poll on initialize', () => {
+    const Parse = require('../Parse');
+    jest.spyOn(console, 'log').mockImplementationOnce(() => {});
+    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {});
+    Parse.initialize('A', 'B');
+    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1);
+  });
+
   it('load StorageController', () => {
     const StorageController = require('../StorageController.browser');
     jest.spyOn(StorageController, 'setItem');