Serverless Architecture · AWS Lambda · SPA Delivery

One Lambda to Rule Them All

  • DevSecOps · Serverless
  • 12 AUG 2025
  • blog
Single Lambda serving API and SPA
Why we like the “single Lambda” pattern

As a Chief Technology Officer, one of your top priorities is enabling teams to iterate rapidly — moving from idea, to proof-of-concept, to limited production release, and finally to a hardened, production-ready rollout. The challenge? Teams often lack the right tools, haven’t built the muscle memory for fail-fast methodologies, or risk losing momentum by splitting focus across too many fronts.

Recently, while working through a complex backend process, we needed a simple React app to support it. Rather than divert backend engineers away from their core work or slow delivery with context switching, we used a streamlined approach: serve the React SPA and JSON APIs from the same AWS Lambda. This kept the team focused, delivery on track, and overhead to a minimum.

When you’re bootstrapping a product or constraining the blast radius in an enterprise proof-of-concept, simplicity wins. In this pattern, the SPA UI and JSON APIs live together inside a single Lambda. The SPA ships as a zipped bundle embedded with the function; runtime logic handles API routes first, then falls back to static file serving with an SPA router catch-all.

The core idea (in ~20 lines)

The snippet below caches a zipped SPA in memory on first request, maps the incoming path, and falls back to index.html for client-side routes:


switch (route)
{
    // ... your API routes here ...

    default:
        if (!_initialized)
        {
            LoadZipIntoMemory("spa-ui.zip");
            _initialized = true;
        }

        string path = request.RawPath?.TrimStart('/') ?? string.Empty;
        if (string.IsNullOrWhiteSpace(path))
            path = "index.html";

        // fallback to index.html for SPA routes
        if (!_fileCache.ContainsKey(path))
            path = "index.html";

        if (_fileCache.TryGetValue(path, out var file))
        {
            return Response.Success(request, file);
        }

        return Response.NotFound(request);
}
                        
How it works
  • Cold load once, then serve from memory: LoadZipIntoMemory extracts files from voice-app-ui.zip into an in‑process dictionary (_fileCache). Subsequent requests are zero‑IO lookups—fast and cheap.
  • Smart path handling: We normalise the path, check for a static file hit, and if it’s a client‑side route (e.g., /settings/123), we return index.html so the SPA router takes over.
  • Single deployment unit: API handlers and UI assets ship together. No S3 bucket, no CloudFront (unless you want it later).

A minimal, production‑lean handler

Here’s a compact example showing a couple of API routes plus the SPA fallback:


public class Function
{
    private static bool _initialized = false;
    private static readonly Dictionary<string, ResponseFile> _fileCache = new();

    public async Task<APIGatewayHttpApiV2ProxyResponse> HandleAsync(APIGatewayHttpApiV2ProxyRequest request)
    {
        var route = $"{request.RequestContext?.Http?.Method?.ToUpperInvariant()} {request.RawPath ?? "/"}";

        // API: health
        if (request.RawPath == "/api/health")
            return Json(200, new { status = "ok", ts = DateTimeOffset.UtcNow });

        // API: example POST
        if (request.RawPath == "/api/do-thing" && request.RequestContext.Http.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
        {
            // validate auth, parse payload, call domain service, etc.
            return Json(202, new { accepted = true });
        }

        // Static + SPA fallback
        if (!_initialized)
        {
            LoadZipIntoMemory("spa-ui.zip"); // populates _fileCache with mime + bytes
            _initialized = true;
        }

        var path = (request.RawPath ?? "/").TrimStart('/');
        if (string.IsNullOrWhiteSpace(path)) path = "index.html";
        if (!_fileCache.ContainsKey(path)) path = "index.html";

        if (_fileCache.TryGetValue(path, out var file))
            return Binary(200, file.Bytes, file.ContentType,
                          new Dictionary<string,string> { ["Cache-Control"] = "public, max-age=300" });

        return Plain(404, "Not found");
    }

    // helpers for JSON/static responses omitted for brevity
}
                        
Positives (why this sings)
  • Ultra‑simple dev & deploy: One artifact; one pipeline. Perfect for fast iteration, hackdays, pilots, and internal tools.
  • Latency is surprisingly good: After cold start, UI files are served from memory—no S3 reads, no network hop.
  • Fewer moving parts: No S3/CloudFront/AWS WAF triage at day one. Add those later when you need global edge or complex caching.
  • Security blast radius: One function to harden (authZ, input validation, headers). Easier to reason about than N services on day one.
  • Version lockstep: UI + API are always in sync—no “frontend v12, API v11” drift.

Negatives (what to watch)
  • Cold starts and bundle size: Large zips slow cold starts. Keep assets lean, pre‑compress, and consider provisioned concurrency for critical paths.
  • Caching is basic: You’ll likely add CloudFront later for global edge caching, HTTP/2, and long‑tail asset TTLs.
  • Scaling the team: As squads split UI/API ownership, a single artifact can bottleneck releases. Break out when you feel the pain.
  • Static asset invalidation: With in‑memory cache, deploys are the invalidation mechanism. Fine for starters; not for large CDNs.
  • Observability coupling: Logs and traces for UI delivery and API logic live together—great for now, noisy later without filters.

Hardening checklist (AppGenie way)
  • Auth: Put auth in a middleware (e.g., Microsoft Entra JWT). Allow anonymous only for static assets and login route.
  • Headers: Add Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Strict-Transport-Security.
  • Compression: Serve pre‑compressed .br/.gz variants if the client advertises support.
  • Types: Send correct MIME types for .js, .css, fonts, and SPA index.html.
  • Config: Keep secrets out of the bundle; use Lambda environment variables / AWS Secrets Manager.

When to evolve

Add S3 + CloudFront when you need edge caching and asset invalidation at scale; split API to separate Lambdas or a service mesh when the team or throughput warrants it. Until then, this pattern keeps velocity high and cognitive load low—our default for new internal tools and POCs.


Code recap

The SPA fallback logic that makes routing “just work”:


string path = request.RawPath?.TrimStart('/') ?? string.Empty;
if (string.IsNullOrWhiteSpace(path))
    path = "index.html";

// fallback to index.html for SPA routes
if (!_fileCache.ContainsKey(path))
    path = "index.html";

if (_fileCache.TryGetValue(path, out var file))
{
    return Response.Success(request, file);
}

return Response.NotFound(request);
                        

Want the full starter?
If your team is fighting deployment complexity and losing momentum, start small. Drop a note to our team and we’ll share a hardened template with auth middleware, CSP headers, Brotli support, and CI/CD.