A recent project required us to implement an interactive map of the United States with a custom counties layer. The layer contained a feature and a few data fields for every county in the U.S. For these cases we often use tilertwo to generate vector tiles from a GeoJSON layer and upload the tiles to an AWS S3 bucket. We then set up an AWS CloudFront distribution in-front of the bucket to serve the tiles, which are added as a vector tile layer to a MapBoxGL.js map.
The project was on a tight deadline and we didn’t yet have the infrastructure setup to do tile serving. Also, while we normally host projects for clients on AWS, this project would eventually be migrated to the client’s internal infrastructure. With this requirement in mind, we wanted to keep the application architecture as simple as possible. The GeoJSON county layer was approximately 6 MB with the additional data fields. This is too large to have every user download when visiting the site, but it felt too small to add the complexity of external tile serving to the application architecture. We decided to explore ways to reduce the size of the file, serve it asynchronously through the app, and let MapBoxGL convert the layer to vector tiles on the fly.
We first tried converting the GeoJSON to TopoJSON, a more compact file format for geo data with lots of shared boundaries between features. This shrunk the file to approximately 500 KB. However, MapboxGL.js doesn’t have native support to render TopoJSON. We would have to convert the layer back to GeoJSON after it was retrieved and before adding it to the map. We tried this, but the performance of the map with the converted GeoJSON layer was slower than the original GeoJSON and not good enough for a production application.
Next, we tried minifying and gzipping the file. This reduced the file size to approximately 1.6 MB. We did this manually, but ideally, this would be done as part of the application bundling pipeline in case the layer is updated. Our application was built on Create React App, which uses Webpack to bundle the application files. As part of the bundling process, JavaScript files and other applications assets are minified, compressed, and combined together into one or more bundles. CRA encourages you to route assets like images and data files through the bundling pipeline to take advantage of the potential file bundling improvements.
Adding the file to the bundling process got it down to approximately 1.6 MB, which was the same size as manually gzipping and minifying the file. This is too large to add to the main bundle file of the app, which would be downloaded when users first visited the page. Could the data be code-split into a separate bundle?
We created a map layer component that loaded the GeoJSON data and added that layer to the map, but used Suspense/Lazy on the individual component. This created a separate bundle for the layer. However, the layer order of the map was thrown off because the layers are ordered as they are loaded. Loading the data layer lazily meant it was added to the map last, above all of the other layers.
import React, { lazy, Suspense } from 'react';
const DataLayer = lazy(() => import('./dataLayer.jsx'));
…
const Map = () => {
return (
<ReactMapGL>
<BaseLayer />
<Suspense fallback={<span>Loading...</span>}>
<DataLayer>
</Suspense>
<AnotherLayer />
</ReactMapGL>
);
}
Another method to implement code-splitting is to use the dynamic import statement. We removed the suspense/lazy statement and implemented this technique within the component. Here’s what the layer component looked like:
const DataLayer = () => {
const [layerData, setLayerData] = useState({
type: 'FeatureCollection',
features: [],
});
useEffect(() => {
import('../data/counties.json').then(layerData => {
setLayerData(layerData);
});
}, []);
return (
<Source id='county-layer' type='geojson' data={layerData}>
<Layer type='fill' />
</Source>
);
};
First, we use the useState
hook to store the data. We start with an empty GeoJSON file which gets added to the map as soon as the map renders so that the layer is rendered in the correct order. Then, we use the useEffect
hook to retrieve the county data. With the empty dependency array, the hook will act like componentDidMount
and only run one time when the component is mounted. Once the data is retrieved, we update the state, and then the data is rendered on the map.
Running the bundle process confirmed that the GeoJSON data is split-off into its own bundle. Another advantage of including the data in the bundling process instead of retrieving it asynchronously as an independent file is that the bundling process will add hash to each bundle file name. This will allow the file to be cached on the user’s machine. If there are changes to the main app bundle but the data layer stays the same, the data layer bundle will keep the same name which will allow the app to retrieve the cached bundle.
1.5 MB build/static/js/3.5d041e59.chunk.js
721.93 KB build/static/js/2.aa8ae9e3.chunk.js
68.27 KB build/static/js/main.ed45a33c.chunk.js
67.77 KB build/static/css/main.cd824b00.chunk.css
40.02 KB build/static/css/2.b0e66777.chunk.css
1.18 KB build/static/js/runtime-main.ae2472b0.js
The map data is included in the 3.5d041… bundle.
We thought this method was a good compromise between architecture complexity, payload size, and performance. Another option that could be explored is using a less detailed county layer, which would have a smaller file size if we didn’t need to support close zoom levels. We also want to experiment more with TopoJSON to see if the performance of the converted GeoJSON layer could be improved.