Repository: parse-community/Parse-SDK-JS
Pull request #1293 (2021-02-10 15:57:24)

fromJSON: Return date if value is a date 
Not sure how often this error would occur, but if you try to create a `ParseObject` with `fromJSON` with a Date, the date field is returned as `{}`

[Related](https://github.com/parse-community/parse-server/issues/7082)


Evidently, this bug also affects the localDataStore tests as:

descending date order should be larger (newer) to smaller (older)

Obj1: createdAt: new Date('2018-08-10T00:00:00.000Z'),
Obj2: createdAt: new Date('2018-08-11T00:00:00.000Z'),
Obj3: createdAt: new Date('2018-08-12T00:00:00.000Z'),

So, this test:

```
    q = new ParseQuery('Item');
    q.descending('_created_at');
    q.fromLocalDatastore();
    results = await q.find();
```

Should return Obj3, Obj2, Obj1. Instead, it returns Obj1, Obj2, then Obj3.

If this doesn't make sense, if you change the order that the objects are loaded [here](https://github.com/parse-community/Parse-SDK-JS/blob/d40f1505a29ae0080d6094f5557f4f372f14a428/src/__tests__/ParseQuery-test.js#L3254) (e.g to Obj2, Obj3, Obj1), the tests will fail on the current master.



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/1872637449).
<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/1293?src=pr&el=h1) Report
> Merging [#1293](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1293?src=pr&el=desc) (5697332) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/c8270a7ff6655f90ab66f4bdb407150d6e03fe31?el=desc) (c8270a7) will **decrease** coverage by `0.01%`.
> The diff coverage is `100.00%`.

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

```diff
@@             Coverage Diff             @@
##            master    #1293      +/-   ##
===========================================
- Coverage   100.00%   99.98%   -0.02%     
===========================================
  Files           58       58              
  Lines         5650     5650              
  Branches      1273     1273              
===========================================
- Hits          5650     5649       -1     
- Misses           0        1       +1     
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1293?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/decode.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1293/diff?src=pr&el=tree#diff-c3JjL2RlY29kZS5qcw==) | `100.00% <100.00%> (ø)` | |
| [src/ParseQuery.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1293/diff?src=pr&el=tree#diff-c3JjL1BhcnNlUXVlcnkuanM=) | `99.85% <0.00%> (-0.15%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1293?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/1293?src=pr&el=footer). Last update [c8270a7...6e51dae](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1293?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

dplewis: Looks good, but the coverage decreased.

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.
    *
diff --git a/src/decode.js b/src/decode.js
index ac4cca0..42512fb 100644
--- a/src/decode.js
+++ b/src/decode.js
@@ -17,7 +17,7 @@ import { opFromJSON } from './ParseOp';
 import ParseRelation from './ParseRelation';
 
 export default function decode(value: any): any {
-  if (value === null || typeof value !== 'object') {
+  if (value === null || typeof value !== 'object' || value instanceof Date) {
     return value;
   }
   if (Array.isArray(value)) {


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..7578c74 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;
@@ -255,11 +256,13 @@ describe('ParseObject', () => {
   });
 
   it('can be inflated from server JSON', () => {
+    const date = new Date();
     const json = {
       className: 'Item',
       createdAt: '2013-12-14T04:51:19Z',
       objectId: 'I1',
       size: 'medium',
+      date: date
     };
     const o = ParseObject.fromJSON(json);
     expect(o.className).toBe('Item');
@@ -268,8 +271,10 @@ describe('ParseObject', () => {
       size: 'medium',
       createdAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
       updatedAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
+      date
     });
     expect(o.dirty()).toBe(false);
+    expect(o.get('date')).toBeInstanceOf(Date);
   });
 
   it('can override old data when inflating from the server', () => {
@@ -1533,6 +1538,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 +2896,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__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js
index c5d7f86..5aea441 100644
--- a/src/__tests__/ParseQuery-test.js
+++ b/src/__tests__/ParseQuery-test.js
@@ -3251,7 +3251,16 @@ describe('ParseQuery LocalDatastore', () => {
       updatedAt: new Date('2018-08-12T00:00:00.000Z'),
     };
 
-    mockLocalDatastore._serializeObjectsFromPinName.mockImplementation(() => [obj1, obj2, obj3]);
+    const obj4 = {
+      className: 'Item',
+      objectId: 'objectId4',
+      password: 123,
+      number: 4,
+      createdAt: new Date('2018-08-12T00:00:00.000Z'),
+      updatedAt: new Date('2018-08-12T00:00:00.000Z'),
+    };
+
+    mockLocalDatastore._serializeObjectsFromPinName.mockImplementation(() => [obj1, obj3, obj2, obj4]);
 
     mockLocalDatastore.checkIfEnabled.mockImplementation(() => true);
 
@@ -3262,25 +3271,19 @@ describe('ParseQuery LocalDatastore', () => {
     expect(results[0].get('number')).toEqual(2);
     expect(results[1].get('number')).toEqual(3);
     expect(results[2].get('number')).toEqual(4);
+    expect(results[3].get('number')).toEqual(4);
 
     q = new ParseQuery('Item');
     q.descending('number');
     q.fromLocalDatastore();
     results = await q.find();
     expect(results[0].get('number')).toEqual(4);
-    expect(results[1].get('number')).toEqual(3);
-    expect(results[2].get('number')).toEqual(2);
-
-    q = new ParseQuery('Item');
-    q.descending('number');
-    q.fromLocalDatastore();
-    results = await q.find();
-    expect(results[0].get('number')).toEqual(4);
-    expect(results[1].get('number')).toEqual(3);
-    expect(results[2].get('number')).toEqual(2);
+    expect(results[1].get('number')).toEqual(4);
+    expect(results[2].get('number')).toEqual(3);
+    expect(results[3].get('number')).toEqual(2);
 
     q = new ParseQuery('Item');
-    q.descending('_created_at');
+    q.ascending('_created_at');
     q.fromLocalDatastore();
     results = await q.find();
     expect(results[0].get('number')).toEqual(2);
@@ -3288,7 +3291,7 @@ describe('ParseQuery LocalDatastore', () => {
     expect(results[2].get('number')).toEqual(4);
 
     q = new ParseQuery('Item');
-    q.descending('_updated_at');
+    q.ascending('_updated_at');
     q.fromLocalDatastore();
     results = await q.find();
     expect(results[0].get('number')).toEqual(2);
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');