Boosting Map Performance with Materialized Views in Postgres

Mohamed Almadih

The Goal
I had geospatial data in a PostgreSQL database that I wanted to display as a heatmap for the last 7 days.
The straightforward approach was to query the raw reports
table every time the page loads.
But when you’re dealing with thousands of rows, that becomes painfully inefficient.
So, how do we make this fast?
The Solution: Materialized Views
A materialized view is like a cached version of a query. Unlike a normal view, which runs the query every time it’s accessed, a materialized view stores the result physically on disk.
This makes it perfect for cases where:
- The query is expensive (geospatial + date filters).
- The data doesn’t change every second.
- You want fast lookups without hammering the main table.
Defining a Materialized View
Here’s a simple example:
1CREATE MATERIALIZED VIEW view_name AS2SELECT3-- query here4FROM5-- target table here6WHERE7-- condition here
In my case, I had a reports
table with a location
column (geometry point with latitude/longitude).
I created a materialized view that snaps points into grid cells and counts reports per cell.
Here’s the migration in Laravel:
1DB::statement('2CREATE MATERIALIZED VIEW mv_heatmap_cells AS3SELECT4MD5(ST_AsText(ST_SnapToGrid(r.location, 0.01, 0.01)))::uuid AS cell_id,5ST_SnapToGrid(r.location, 0.01, 0.01) AS geom,6COUNT(*) AS report_count,7CURRENT_DATE AS data_until_date8FROM reports r9WHERE r.created_at >= (CURRENT_DATE - INTERVAL \'6 days\')10AND r.created_at < (CURRENT_DATE + INTERVAL \'1 day\')11GROUP BY geom;12');1314DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS uidx_mv_heatmap_cells_cell_id ON mv_heatmap_cells (cell_id);');15DB::statement('CREATE INDEX IF NOT EXISTS idx_mv_heatmap_cells_week_geom ON mv_heatmap_cells (cell_id, geom);');16DB::statement('CREATE INDEX IF NOT EXISTS idx_mv_heatmap_cells_geom_gist ON mv_heatmap_cells USING GIST (geom);');
What’s Happening Here?
- Cell ID: I used an MD5 hash of the grid cell as a unique identifier.
- ST_SnapToGrid: Groups all points into a grid of
0.01 x 0.01
units. - Report Count: Counts how many reports fall into each grid cell.
- 7-Day Filter: Only considers reports created in the last 7 days.
- Indexes: Added for faster queries.
Keeping It Fresh
A materialized view doesn’t update automatically. You need to refresh it:
1REFRESH MATERIALIZED VIEW CONCURRENTLY mv_heatmap_cells;
In Laravel, I made a command and hooked it into the scheduler:
1class RefreshHeatmapCells extends Command2{3protected $signature = 'app:refresh-heatmap-cells';4protected $description = 'Refresh heatmap cells materialized view.';56public function handle()7{8DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_heatmap_cells;');9}10}
This way, the view stays up-to-date without blocking queries.
Displaying on a Map
Finally, let’s put it on a map using Vue and Google Maps:
1<script lang="ts" setup>2import axios from 'axios';3import { ref, watch, onMounted } from 'vue';45const center = ref({ lat: 30.89, lng: 29.87 });6const heatmapData = ref([]);7const mapRef = ref<google.maps.Map | null>(null);89const fetchHeatmapData = () => {10axios.get(route('reports-heatmap')).then((res) => {11heatmapData.value = res.data.rows.map((feature: any) => {12const geometry = JSON.parse(feature.geometry);13const props = JSON.parse(feature.properties);14return {15location: new google.maps.LatLng(geometry.coordinates[1], geometry.coordinates[0]),16weight: props.report_count * 1017};18});19});20};2122const mapLoaded = (map: any) => { mapRef.value = map; };2324watch([mapRef], () => { if (mapRef.value) fetchHeatmapData(); });25</script>2627<template>28<Map :center="center" :zoom="8" @map:loaded="mapLoaded" class="h-[500px]">29<GMapHeatmap :data="heatmapData" />30</Map>31</template>
Here, the report count is used as the weight of each heatmap point.
You could get fancier by adding weights based on priority, severity, or other attributes.
Wrapping Up
With materialized views, you can:
- Pre-compute expensive queries.
- Keep your heatmaps responsive even with thousands of rows.
- Control when to refresh the data.
This approach gives you the best of both worlds: performance and flexibility. 🚀