DEV Community

Cover image for Scaling CrimeLens: From Performance Baseline to Measurable Backend Improvements
Abubakar Ahmed
Abubakar Ahmed

Posted on

Scaling CrimeLens: From Performance Baseline to Measurable Backend Improvements

I recently completed a system design and performance optimization cycle for CrimeLens, my full-stack crime reporting, mapping, and analytics platform.

CrimeLens connects citizens, public users, police officers, and administrators through different workflows. Citizens can submit crime reports, police and administrators can review and manage them, and approved crime data becomes available through maps, statistics, trends, and geospatial searches.

The system is built as a PERN modular monolith using React, TypeScript, Node.js, Express, PostgreSQL, PostGIS, and Supabase.

But building features was only one part of the project. I also wanted to understand how the backend behaved under real load, where its bottlenecks were, and whether system design improvements would produce measurable results.

Establishing the baseline

Before implementing the system design changes, I created k6 tests covering:

  • Normal API traffic
  • Gradually increasing stress
  • Sudden traffic spikes
  • Authentication flows
  • Crime-report submission
  • Statistical and geospatial endpoints
  • Recovery after heavy traffic

The baseline showed that the application was functionally stable, but response times increased sharply as concurrency grew.

Under normal load, the overall p95 response time was 5.13 seconds. During the stress test, it increased to 36.64 seconds, while p99 reached 41.61 seconds.

The /api/stats/summary endpoint was one of the main bottlenecks, and the results also indicated database connection-pool contention and request queueing.

What I implemented

Based on the baseline findings, I introduced several backend and infrastructure improvements:

  • Added PostgreSQL indexes and optimized frequently executed queries
  • Improved PostGIS and geospatial query handling
  • Added pagination to prevent unnecessarily large API responses
  • Increased and monitored the database connection pool
  • Implemented Redis cache-aside caching with TTL and cache invalidation
  • Added Redis-backed rate limiting
  • Added request validation and sanitization
  • Hardened the API using Helmet and stricter CORS configuration
  • Enabled gzip and Brotli response compression
  • Added structured Pino logging and request IDs
  • Added Prometheus metrics and Grafana dashboards
  • Containerized the application using Docker
  • Added Nginx as a reverse proxy and tested multi-instance load balancing
  • Added BullMQ background processing for Cloudinary deletion jobs
  • Added CI/CD checks using GitHub Actions, CodeQL, npm audit, Docker scanning, and Trivy

After completing these changes, I reran the original k6 baseline scripts so the final results could be compared with the earlier measurements under conditions that were as similar as possible.

Baseline vs final k6 results

Normal-load test

Metric Before After Improvement
Total requests 24,667 53,972 2.19x more
Throughput 25.5 req/s 55.4 req/s 2.17x higher
p50 latency 1,230 ms 25 ms 49.2x faster
p95 latency 5,130 ms 655 ms 7.83x faster
p99 latency 7,690 ms 1,540 ms 4.99x faster
HTTP error rate 0.10% 0.006% ~16.7x lower

The optimized system processed more than twice as many requests while reducing median latency from 1.23 seconds to 25 milliseconds.

Stress test

Metric Before After Improvement
Total requests 33,106 289,121 8.73x more
Throughput 32.3 req/s 282.2 req/s 8.74x higher
p50 latency 8,170 ms 119 ms 68.7x faster
p95 latency 36,640 ms 2,800 ms 13.09x faster
p99 latency 41,610 ms 4,335 ms 9.60x faster
HTTP errors 0 0 No regression

The clearest difference appeared under stress. Throughput increased from 32.3 to 282.2 requests per second, while p95 latency dropped from 36.64 seconds to 2.8 seconds.

The stress test completed without HTTP request failures in both runs. However, the final p95 result was still above the configured two-second threshold, which shows that there is still room for optimization under extreme concurrency.

Spike and recovery test

Metric Before After Improvement
Requests processed 4,997 51,198 10.25x more
Spike-phase p95 45,191 ms 2,964 ms 15.25x faster
Recovery-phase p95 10,370 ms 202 ms 51.34x faster

The recovery result was especially important. After the traffic spike ended, p95 latency returned to 202 milliseconds, compared with more than 10 seconds in the baseline run.

The final spike run did record 31 connection-refused errors against the local Nginx/Docker entry point. This suggests a brief availability problem at the local container or reverse-proxy edge during the sudden burst. It is a useful finding for future investigation rather than something to hide behind the overall performance improvements.

Endpoint-level comparison

Endpoint Baseline p95 Final p95 Improvement
Statistics summary 8,050 ms 1,104 ms 7.29x faster
Radius search 2,200 ms 583 ms 3.77x faster

The summary endpoint remained one of the heavier operations, but its p95 latency dropped from 8.05 seconds to 1.10 seconds. The geospatial radius endpoint improved from 2.2 seconds to 583 milliseconds.

A note about the test environment

These tests were conducted on my local Windows machine rather than production infrastructure.

The original baseline backend ran locally in Node.js development mode, while the final system ran locally through its current Docker, Nginx, Redis, and application-container setup. Both environments communicated with a remote Supabase PostgreSQL/PostGIS free-tier database.

The same original k6 scripts were used for the final comparison, although the architecture itself changed because those changes were the subject of the test. The database also contained a relatively small dataset.

Therefore, these results should be understood as a controlled before-and-after engineering comparison, not guaranteed production benchmarks. Production performance would also depend on the hosting platform, database tier, deployment region, network latency, available CPU and memory, and the size of the real dataset.

Final outcome

This exercise reinforced an important lesson for me: system design is not simply about adding Redis, Docker, Nginx, or monitoring tools.

The real process was:

Measure the existing system → identify bottlenecks → introduce targeted improvements → observe the system → rerun the same tests → compare the results -> find New Problems -> repeat

The final system handled substantially more traffic, responded much faster under normal and stressful conditions, recovered more quickly after spikes, and now has much better visibility into its runtime behaviour.

There are still areas to improve, particularly the local edge interruption observed during the spike test and the remaining stress-test p95 threshold, but the difference between the baseline and final run is measurable and significant.

Repository: https://github.com/abubakar-ahmed-dev/Crime-Lens

Top comments (0)