Off the worker, into Lambda: making 360° cubemap generation ~4× faster
Tony Duong
Jun 15, 2026 ・ 13 min

This is the English version of an article I originally published in Japanese on the Spacely tech blog: 360°キューブマップ生成をワーカーからLambdaへ移して約4倍高速化した話.
Hi, I'm Tony Duong, a Rails backend engineer at Spacely. I work on the Spacely platform day to day. This post is about how we took one of the busiest, most CPU-hungry jobs in our spacely_web Rails application — turning a 360° photo into a cubemap — off our Sidekiq workers and onto AWS Lambda, and made the end-to-end experience roughly 4× faster in the process.
What is a cubemap, and why do we generate one?
When a user uploads a 360° room photo, it arrives as a single equirectangular JPEG — the familiar "unwrapped sphere" image, twice as wide as it is tall. That projection is great for storage but expensive to render in real time.

An equirectangular panorama. Notice how the ceiling and floor are stretched and bent — the whole 360° room is squashed into one rectangular image.
So before the photo can be shown, we convert it into a cubemap: the same scene re-projected onto the six faces of a cube — right, left, up, down, front, and back (pano_r / pano_l / pano_u / pano_d / pano_f / pano_b). Here are the six faces produced from the panorama above:
Front (pano_f) |
Right (pano_r) |
Back (pano_b) |
Left (pano_l) |
Up (pano_u) |
Down (pano_d) |
The same room as six undistorted square faces. Unlike the stretched equirectangular image, each face is a normal flat photo — exactly what a GPU wants to texture onto a cube.
These six faces are what the panorama player actually renders. The viewer textures them onto the inside of a cube around the camera, and as you drag to look around a room, you're looking at those flat faces — which GPUs can sample far more cheaply than a distorted equirectangular image. Every 360° tour you pan around on Spacely is being drawn from a cubemap generated by this job.
You can explore Spacely's 360° player here: https://info.spacely.co.jp/realestate-vr/.
This conversion runs thousands of times a day, and it sits right on the upload path — so its speed is something users feel directly.
The problem: a CPU-bound job sharing the Sidekiq pool
The legacy job, CreateCubeMapJob, ran the CPU-intensive krpano 1.1x conversion entirely on the Sidekiq worker, then uploaded the results to S3.
Three things made this painful:
-
It starved everything else. The conversion is CPU-intensive, and it ran on the same Sidekiq workers as the rest of our jobs. A single Spacely project can hold up to 50 panoramas, so when a user uploaded a full project at once, dozens of these jobs ran in parallel, pinned the CPU, and slowed down unrelated jobs while the queue backed up. Sidekiq has practical scaling limits, so burst uploads turned into latency spikes.
-
Its runtime was unpredictable. Because the job shared workers with everything else, how long a single conversion took depended entirely on how many other cubemap jobs — and other CPU-bound jobs — happened to be running at the same moment. When the workers had spare capacity it finished quickly (around 5–10 seconds); in busy periods the very same conversion could take up to ~2 minutes.
-
krpano 1.1x was overdue for an update. It's a 2019 release. We hoped a newer library would help, but under realistic concurrent load (below) the gains were negligible.

Under a burst, CPU-bound krpano jobs (orange) saturate every Sidekiq task, and unrelated jobs (grey) wait their turn — which is why both cubemap and non-cubemap work slowed down.
The tempting non-fix: just scale harder
The obvious first reaction is to throw hardware at it: give the Sidekiq workers bigger CPUs (vertical scaling) and run more of them (horizontal scaling). That does buy headroom, and it's worth doing up to a point — but it doesn't actually fix the problem. The conversion is still competing for the same workers as every other job, so the moment load grows past whatever new capacity we provisioned, we're right back to the same contention and the same unpredictable runtimes. Scaling makes the wall further away; it doesn't move us off the wall.
The better fix is architectural — and not a dramatic one. Instead of rewriting the logic, we delegate the heavy part to the environment built for it. The expensive, CPU-bound conversion belongs on isolated, on-demand compute (Lambda), not on a worker pool shared with latency-sensitive jobs. Crucially, we did this while keeping the interface identical: the new job has the same inputs and the same side effects as the old one — it reads the same source and writes the same six faces to the same place in S3. Keeping the contract unchanged is what let us swap the implementation with confidence that the blast radius stays minimal: nothing downstream can tell which job produced the cubemap.
The rest of this post is the two halves of that change — evaluating conversion engines, and moving the work off the worker.
Evaluating conversion engines
We benchmarked three conversion engines: our current krpano 1.1x (2019), a newer krpano 1.2x (2025), and the open-source Python library py360convert. The test simulated a realistic burst: 50 images uploaded at once (50 Sidekiq jobs enqueued in a short window). Infrastructure was unchanged — same ECS server that hosts Sidekiq, same CPU and memory — only the conversion library differed. Per-image results (p50 = median):
| Engine | p50 | p95 |
|---|---|---|
| krpano 1.1x | 19.6s | 39.4s |
| krpano 1.2x | 17.6s | 55.5s |
| py360convert | 21.4s | 38.3s |
There was no noticeable improvement from swapping libraries alone. All three landed in the same ballpark under concurrent load — the CPU contention of running dozens of conversions on shared Sidekiq workers dwarfed any per-engine speed difference. That isn't enough to pick a winner yet. Let's see how performance changes when we change the underlying architecture.
The new design: offloading to Lambda
We moved the conversion into an AWS Lambda: an isolated, horizontally-scaling environment where 50 concurrent conversions perform exactly the same as one.
The new job, CreateCubeMapV2Job, orchestrates the work — Sidekiq stays light while each conversion runs in its own isolated Lambda. Image bytes flow directly between S3 and Lambda; the worker never handles them.

The worker coordinates; each conversion gets its own Lambda. Image data stays off Sidekiq:
No bytes through the worker. The job does a server-side S3 copy, then hands Lambda presigned URLs: a GET to read the source and PUTs to write each output. Image data goes S3 → Lambda → S3 directly.
Benchmarking engines on Lambda
With the CPU contention gone, we re-ran the same 50-image burst test on Lambda — this time comparing krpano 1.2x and py360convert (we skipped krpano 1.1x and tested with the newer library instead). Each conversion ran in its own isolated Lambda (2048 MB); only the conversion library differed. Per-image results (p50 = median):
5K (5376×2688)
| Engine | p50 | p90 |
|---|---|---|
| krpano 1.2x | 6.99s | 8.33s |
| py360convert | 5.71s | 10.95s |
11K (11008×5504)
| Engine | p50 | p90 |
|---|---|---|
| krpano 1.2x | 21.26s | 25.17s |
| py360convert | 10.24s | 16.44s |
Measured at 2048 MB. For both engines at 11K, increasing Lambda memory and vCPU beyond that did not improve performance — 2048 MB was the sweet spot.
Compare that to the Sidekiq table above — where every engine landed in the ~18–21s range regardless of library. Two conclusions:
-
The architecture change was the critical win. Moving off shared workers to isolated Lambda cut per-image times from ~18–21s down to single-digit seconds at 5K. Library choice alone on Sidekiq had barely moved the needle; isolation did.
-
On Lambda,
py360convertis the clear winner — and the gap grows with resolution. At 5K it is slightly faster than krpano 1.2x (5.71s vs 6.99s p50). At 11K the advantage roughly doubles (10.24s vs 21.26s p50).
We chose py360convert for production. It is the fastest option where it matters most — our large panoramas — and it is a pure-Python library with no external binary or license to manage. With the output images generated at the same (cube-face) resolution, quality was visually indistinguishable between the two engines.
The conversion itself — the py360convert call inside our Lambda handler:
def _convert_faces(image: Image.Image, face_width: int = None) -> Dict[str, Image.Image]:
np_img = np.array(image)
...
faces = py360convert.e2c(np_img, face_w=face_width, cube_format="dict")
...
Tuning Lambda memory for py360convert
On Lambda, the memory setting is really a performance knob, not just a capacity one — more memory comes with proportionally more CPU (and at 1,769 MB a function gets the equivalent of one full vCPU). So there's no single right value: too little and large panoramas run out of memory; too much and we pay for headroom that buys no extra speed. We benchmarked each image size across a range of memory settings to find its sweet spot.
For a 5376×2688 panorama, we tested from 1024 MB up to 4096 MB:
| Lambda memory | p50 | p90 |
|---|---|---|
| 1024 MB | 9.10s | 15.48s |
| 2048 MB | 5.71s | 10.95s |
| 4096 MB | 5.97s | 10.69s |
Performance improves sharply from 1024→2048 MB, then flattens — 4096 MB is no faster than 2048. The same shape holds for a larger 11008×5504 (~60 MP) panorama, except there 1024 MB isn't even viable:
| Lambda memory | p50 | p90 |
|---|---|---|
| 1024 MB | out of memory | — |
| 2048 MB | 10.24s | 16.44s |
| 4096 MB | 10.12s | 16.94s |

Both curves bend at the same place: time drops sharply up to 2048 MB, then goes flat — the extra memory (and the CPU that comes with it) is no longer the bottleneck.
The takeaway: 2048 MB is the sweet spot for our typical panoramas, with larger tiers held in reserve for oversized inputs. In production, CreateCubeMapV2Job routes by pixel count to one of three Lambda endpoints — same handler code, different memory size:
Routing rules: ≤ 11K → 2048 MB · ≤ 16K → 3072 MB · > 16K → 4096 MB. Every image gets enough memory to run; none pays for headroom it can't use.
Cost
Even at peak the bill stays modest. On a busy day we run on the order of ~19,000 conversions (~570,000/month). At 2048 MB and ~17s average duration, in the Tokyo region (Lambda pricing):
- Compute: 570,000 × 2 GB × 17s = 19,380,000 GB-seconds × $0.0000166667 ≈ $323/month
- Requests: 570,000 × $0.20 / 1M ≈ $0.11/month
- Plus a couple of dollars of API Gateway.
So the whole thing lands at roughly $350/month in the worst case — a fair price for taking a heavy, bursty workload off the shared worker pool and getting stable, predictable latency in return.
Results
We tested the realistic worst case — a full project of 50 large (11008×5504) panoramas uploaded at once (50 being the most a project can hold), on production-spec infrastructure — measuring from upload to a ready-to-use player:
| Pipeline | End-to-end |
|---|---|
| Sidekiq + krpano 1.1x (before) | ~8m 50s |
| Lambda + krpano 1.2x | |
| Lambda + py360convert (after) | ~1m 50s |
A ~4× speedup end-to-end — and just as importantly, the Lambda numbers are stable. On Sidekiq, job time swung wildly with how busy the workers were; on Lambda, isolated execution means 50 concurrent jobs finish in about the same time as one.
We're rolling this out gradually behind a per-company feature flag — a phased rollout rather than a traffic-split canary: we enable the new path company by company, not request by request.
- Limited release — flip the flag for a subset of companies; watch performance and cost while the rest stay on Sidekiq + krpano 1.1x.
- All companies — enable Lambda + py360convert everywhere.
- Cleanup — once the new path has proven itself, delete the old krpano job and its tooling entirely.
Takeaways
- Move CPU-bound, bursty work off your shared worker pool. The critical win was architectural isolation — not swapping libraries on Sidekiq. Once on Lambda,
py360convertpulled ahead, especially on large (11K) panoramas. - Keep the remote worker thin and pass it presigned URLs. Lambda reads and writes S3 directly — image bytes never pass through your app servers.
- Right-size the remote compute to the input. Selecting the Lambda memory tier by the source's pixel count avoided both out-of-memory failures on huge panoramas and overpaying on small ones.
We're hiring engineers at Spacely. If this kind of backend work sounds interesting, check out our recruit page.
Front (
Right (
Back (
Left (
Up (
Down (