diff --git a/src/objectliterals.jsdoc b/src/objectliterals.jsdoc
index 971291cfca..240dd79e79 100644
--- a/src/objectliterals.jsdoc
+++ b/src/objectliterals.jsdoc
@@ -904,6 +904,18 @@
* @property {string} url Url.
*/
+/**
+ * @typedef {Object} olx.source.RemoteVectorOptions
+ * @property {Array.
|undefined} attributions Attributions.
+ * @property {ol.Extent|undefined} extent Extent.
+ * @property {function(ol.Extent, number): string} extentUrlFunction Extent URL function.
+ * @property {Object.|undefined} headers Headers.
+ * @property {ol.format.Feature} format Format.
+ * @property {function(ol.Extent, number): Array.} loadingFunction Loading function.
+ * @property {string|undefined} logo Logo.
+ * @property {ol.proj.ProjectionLike} projection Projection.
+ */
+
/**
* @typedef {Object} olx.source.TileJSONOptions
* @property {null|string|undefined} crossOrigin crossOrigin setting for image
diff --git a/src/ol/source/remotevectorsource.exports b/src/ol/source/remotevectorsource.exports
new file mode 100644
index 0000000000..3e984107fd
--- /dev/null
+++ b/src/ol/source/remotevectorsource.exports
@@ -0,0 +1 @@
+@exportSymbol ol.source.RemoteVector
diff --git a/src/ol/source/remotevectorsource.js b/src/ol/source/remotevectorsource.js
new file mode 100644
index 0000000000..926c6fc051
--- /dev/null
+++ b/src/ol/source/remotevectorsource.js
@@ -0,0 +1,97 @@
+// FIXME cache expiration
+
+goog.provide('ol.source.RemoteVector');
+
+goog.require('ol.extent');
+goog.require('ol.source.FormatVector');
+goog.require('ol.structs.RBush');
+
+
+
+/**
+ * @constructor
+ * @extends {ol.source.FormatVector}
+ * @param {olx.source.RemoteVectorOptions} options Options.
+ */
+ol.source.RemoteVector = function(options) {
+
+ goog.base(this, {
+ attributions: options.attributions,
+ extent: options.extent,
+ format: options.format,
+ headers: options.headers,
+ logo: options.logo,
+ projection: options.projection
+ });
+
+ /**
+ * @private
+ * @type {ol.structs.RBush.<{extent: ol.Extent}>}
+ */
+ this.loadedExtents_ = new ol.structs.RBush();
+
+ /**
+ * @private
+ * @type {function(ol.Extent, number): Array.}
+ */
+ this.loadingFunction_ = options.loadingFunction;
+
+ /**
+ * @private
+ * @type {function(ol.Extent, number): string}
+ */
+ this.extentUrlFunction_ = options.extentUrlFunction;
+
+ /**
+ * @private
+ * @type {Object.}
+ */
+ this.loadedFeatures_ = {};
+
+};
+goog.inherits(ol.source.RemoteVector, ol.source.FormatVector);
+
+
+/**
+ * @inheritDoc
+ */
+ol.source.RemoteVector.prototype.addFeaturesInternal = function(features) {
+ /** @type {Array.} */
+ var notLoadedFeatures = [];
+ var i, ii;
+ for (i = 0, ii = features.length; i < ii; ++i) {
+ var feature = features[i];
+ var featureId = feature.getId();
+ if (!(featureId in this.loadedFeatures_)) {
+ notLoadedFeatures.push(feature);
+ this.loadedFeatures_[featureId] = true;
+ }
+ }
+ goog.base(this, 'addFeaturesInternal', notLoadedFeatures);
+};
+
+
+/**
+ * @inheritDoc
+ */
+ol.source.RemoteVector.prototype.loadFeatures = function(extent, resolution) {
+ var loadedExtents = this.loadedExtents_;
+ var extentsToLoad = this.loadingFunction_(extent, resolution);
+ var i, ii;
+ for (i = 0, ii = extentsToLoad.length; i < ii; ++i) {
+ var extentToLoad = extentsToLoad[i];
+ var alreadyLoaded = loadedExtents.forEachInExtent(extentToLoad,
+ /**
+ * @param {{extent: ol.Extent}} object Object.
+ * @return {boolean} Contains.
+ */
+ function(object) {
+ return ol.extent.containsExtent(object.extent, extentToLoad);
+ });
+ if (!alreadyLoaded) {
+ var url = this.extentUrlFunction_(extentToLoad, resolution);
+ this.loadFeaturesFromURL(url);
+ loadedExtents.insert(extentToLoad, {extent: extentToLoad.slice()});
+ }
+ }
+};