Raster pipelines do not belong in the request cycle
Geoverdant turns satellite imagery into vegetation heatmaps over real land parcels. The task underneath that sentence is: pull multi-spectral NASA HLS scenes for a parcel, align the spectral bands against each other, and compute a vegetation index across rasters that are far too large to hold in memory the way a normal web request holds its data. None of that fits comfortably inside the time a browser is willing to wait on a request, and I treated pretending otherwise as the first mistake worth not making.
Why the request cycle is the wrong place
Start with what a single NASA HLS scene actually is. It is not a small image. It is a multi-band raster covering a swath of land, at a resolution meant for scientific analysis, and a parcel of interest can span multiple scenes or multiple capture dates before there is enough clean, cloud-free coverage to work with. Before I can compute any vegetation index, I have to align the bands involved against each other, because they are not guaranteed to line up pixel for pixel coming off the source data. Doing that alignment and the index computation over a real scene is not a fast operation, and it is not a small amount of memory either.
A request that takes minutes is not a slow request. It is a broken one. It holds an application worker open for the entire duration, which is capacity the rest of the product does not have while that worker is busy. It times out at every proxy sitting between the browser and the process (the load balancer, the reverse proxy, the framework's own request timeout), usually at different thresholds, so the failure mode is inconsistent depending on which layer gives up first. And when a client does time out and retries, the retry does not resume anything. It starts the whole scene retrieval and raster computation over from nothing, on a system that was already struggling to finish it once. I don't think of that as a slow request degrading gracefully under load; it compounds.
Splitting the system
I fixed this with a service boundary, not a longer timeout. A dedicated FastAPI processing service, backed by background workers, owns imagery retrieval and all of the raster maths: pulling NASA HLS tiles, aligning bands, computing vegetation indices. The Next.js and NestJS product layer in front of it never touches a raster directly. It only ever asks the processing service for a result, and the result it gets back is either a finished, cached heatmap or a clear signal that one is still being computed.
That distinction matters to me: this is a service boundary, not just a queue dropped in front of the same code. The processing service has its own responsibilities, its own failure modes, and its own scaling characteristics, entirely separate from the request/response layer the frontend talks to. Background workers can be scaled against how much raster work is queued, independently of how much request traffic the product layer is handling, because I no longer coupled the two to the same process. A burst of scene processing does not compete with the frontend's own request load for the same capacity, because they are not the same capacity anymore.
Band maths with Rasterio
The raster computation itself is where the memory problem actually lives, and where I found the first draft easiest to get wrong. Reading a whole multi-spectral scene into memory band by band, as full arrays, is the naive approach, and it scales directly with scene size: a larger scene or a higher-resolution capture means a larger memory footprint, with no ceiling other than what the worker happens to have available.
I used Rasterio's windowed reads instead: reading a raster in tiles, or windows, rather than pulling the entire band into memory at once. I do the vegetation index computation the same way, window by window, band by band, rather than materialising a full-scene array of every band before doing any arithmetic. The output heatmap is assembled from those windows as they are computed. The practical effect is that memory use for a given worker stays roughly flat regardless of how large the input scene is, because the worker is only ever holding one window's worth of data at a time rather than the whole scene. That is what makes it possible to run this on background workers with a fixed memory budget instead of provisioning for the largest scene the system might ever see.
The cache is the actual product surface
From the frontend's point of view, the entire pipeline above collapses into one contract: give me a heatmap for this parcel. Everything upstream of that (which scenes were pulled, how bands were aligned, how the index was computed, which worker did it) is an implementation detail the product layer never needs to know about.
I serve that contract from a cache keyed on the parcel and the inputs that determine the result, the parcel identifier and the imagery date range being the parts that matter, since a different date range or a different parcel is genuinely a different computation. A request for a parcel that has already been processed for the relevant period returns the cached heatmap immediately, without touching the raster pipeline at all. A request the cache has not seen triggers background processing, and the product layer waits on that result (asynchronously, off its own request thread) rather than blocking a request on raster work directly.
The cache has to invalidate on the things that actually change the answer: new imagery becoming available for the parcel over time, most obviously. A heatmap computed from the most recent clean scene is only correct until a newer scene exists, so I treat the cache key and its invalidation policy as more than an optimisation layered on top of the design: they are what makes "give me a heatmap for this parcel" a fast, honest contract instead of a slow one hiding behind a cache that occasionally lies.
The general lesson
When a piece of work does not fit inside a request, because of its size, its duration, or both, I don't reach for a longer timeout, because a longer timeout just moves where the failure shows up without changing that the work does not belong there. I reach for a different boundary: a service that owns the slow work on its own terms, workers that process it without holding anything else hostage, and a cache that lets the product surface answer fast by asking a question (has this already been computed?) instead of doing the computation itself.
This came out of Geoverdant.