Edge and serverless hybrid: running code close to users

1. Overview

Users far from your server will suffer high network latency, no matter how fast your code runs. The edge and serverless hybrid model solves this: lightweight logic runs at points close to users, while heavy processing runs in serverless functions across a few central regions.

This article explains the concept, why this model is gaining attention, and how to divide work between the two layers. You’ll see concrete examples with Spring Boot 3.x, Spring Cloud Function, and a simple edge function. At the end are cases where you should not use this model.

2. Edge, serverless, and the hybrid model

2.1. What is edge

Edge is distributed infrastructure at many locations, typically associated with CDN networks. Historically, edge was mainly used to cache static files. Today, many platforms allow running code directly at the edge, called edge functions.

Edge runtimes typically have strict limitations:

  • Short execution time per request.
  • Small memory, no full file system.
  • Usually supports JavaScript/TypeScript or WebAssembly, rarely runs a full JVM.
  • Not suitable for maintaining long-lived connections to traditional databases.

2.2. What is serverless

Serverless (Function-as-a-Service or auto-scaling containers) lets you deploy code without managing servers. The platform scales automatically with request volume, including scaling to zero. In return, you must accept cold starts when new instances spin up.

Compared to edge, serverless typically runs in fewer regions. However, it has more resources, full Java support, and sits closer to the database.

2.3. Why the hybrid model

Each layer has its own strengths:

┌──────────┐    close  ┌──────────────┐  farther  ┌────────────────────┐    ┌──────────┐
│ User     │ ────────▶ │ Edge         │ ─────────▶ │ Serverless origin  │ ─▶ │ Database │
│          │ ◀──────── │ (cache, rout,│ ◀───────── │ (Spring Boot /     │ ◀─ │          │
└──────────┘           │  auth)       │            │  Spring Cloud Func)│    └──────────┘
  • Edge handles work that needs to be fast and doesn’t depend on central data: caching, language-based redirects, preliminary token checks, bot blocking, A/B testing.
  • Serverless origin handles business logic requiring consistent data: price calculation, order creation, payment.

General principle: push read-heavy, slow-changing work to edge; keep write-heavy and consistency-critical work at origin.

3. Why this model is gaining attention

There are several key reasons:

  1. Perceived latency. Many requests are answered immediately at the edge, without reaching origin. Users far from the central region benefit most.
  2. Reduced origin load and cost. Every request cached at the edge is one fewer serverless function invocation.
  3. Location-based personalization. Edge knows where users come from, so it can select appropriate language, currency, or content version.
  4. Data residency requirements. Edge can route requests to origin in the correct legal jurisdiction.
  5. The Java ecosystem has adapted better. Native images via GraalVM and fast-startup techniques reduce cold starts, making Java more suited to serverless than before.

4. Origin: teaching edge how to cache with Spring Boot

The hybrid model starts at the origin. Edge only caches correctly when origin returns clear Cache-Control headers. Consider a product catalog API:

public record Product(String id, String name, BigDecimal price) {}

@Component
public class ProductCatalog {
    private final Map<String, Product> products = Map.of(
        "p-1", new Product("p-1", "Huế nón lá", new BigDecimal("150000")),
        "p-2", new Product("p-2", "Áo bà ba", new BigDecimal("320000"))
    );

    public Optional<Product> findById(String id) {
        return Optional.ofNullable(products.get(id));
    }
}

The controller returns different cache headers for different data types:

@RestController
@RequestMapping("/api")
public class ProductController {

    private final ProductCatalog catalog;

    public ProductController(ProductCatalog catalog) {
        this.catalog = catalog;
    }

    @GetMapping("/products/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable String id) {
        return catalog.findById(id)
            .map(product -> ResponseEntity.ok()
                .cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
                    .cachePublic()
                    .sMaxAge(Duration.ofMinutes(5))
                    .staleWhileRevalidate(Duration.ofMinutes(1)))
                .body(product))
            .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/cart")
    public ResponseEntity<List<String>> getCart(@RequestHeader("Authorization") String token) {
        return ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())
            .body(List.of("p-1"));
    }
}

What each directive means:

  • max-age=30: browsers may use the cached copy for 30 seconds.
  • public: shared caches (edge, CDN) are allowed to store the response.
  • s-maxage=300: shared caches keep the response for 5 minutes. Edge keeps it longer than browsers, because you can purge edge cache deliberately but not browser cache.
  • stale-while-revalidate=60: when expired, edge still serves the old copy for up to 60 seconds and refreshes it in the background. Users don’t wait for origin.
  • The cart is personal data, so use no-store so no layer stores it.

Check with curl:

curl -i http://localhost:8080/api/products/p-1
HTTP/1.1 200
Cache-Control: max-age=30, public, s-maxage=300, stale-while-revalidate=60
Content-Type: application/json

{"id":"p-1","name":"Huế nón lá","price":150000}

5. Business logic in serverless with Spring Cloud Function

Computations requiring central data should run in serverless functions. Spring Cloud Function lets you write logic as plain Function, then run the same code over local HTTP or via a serverless platform adapter.

Add dependencies (declare the spring-cloud-dependencies BOM with a version matching your Spring Boot):

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-function-web</artifactId>
    </dependency>
</dependencies>

Write a function to quote an order by country:

public record OrderRequest(String country, BigDecimal subtotal) {}

public record OrderQuote(BigDecimal subtotal, BigDecimal tax, BigDecimal total) {}

@Configuration
public class PricingFunctions {

    private static final Map<String, BigDecimal> TAX_RATES = Map.of(
        "VN", new BigDecimal("0.10"),
        "SG", new BigDecimal("0.09")
    );

    @Bean
    public Function<OrderRequest, OrderQuote> quoteOrder() {
        return request -> {
            BigDecimal rate = TAX_RATES.getOrDefault(request.country(), BigDecimal.ZERO);
            BigDecimal tax = request.subtotal().multiply(rate).setScale(0, RoundingMode.HALF_UP);
            return new OrderQuote(request.subtotal(), tax, request.subtotal().add(tax));
        };
    }
}

When running locally, spring-cloud-function-web automatically exposes the quoteOrder bean at POST /quoteOrder:

curl -s -X POST http://localhost:8080/quoteOrder \
  -H "Content-Type: application/json" \
  -d '{"country":"VN","subtotal":150000}'
{"subtotal":150000,"tax":15000,"total":165000}

Logic lives in a plain Function, independent of HTTP or platform. This way you can switch between serverless providers by swapping adapters, without changing the business logic.

Note: With Java on serverless, cold starts are real. Keep functions lean, minimize unnecessary beans, and consider native images if your platform supports them.

6. Edge functions: routing, early blocking, and forwarding

Edge layers typically run JavaScript. Many edge runtimes follow the standard web Fetch API model: accept Request, return Response. The example below illustrates the idea. Header naming and deployment depend on your platform.

const ORIGIN = "https://origin.nonla.example";

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const country = request.headers.get("x-edge-country") ?? "VN";

    // 1. Redirect homepage by language, no need to call origin
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. Block cart requests without token early
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 3. Forward to origin, including country info
    const headers = new Headers(request.headers);
    headers.set("X-Edge-Country", country);
    return fetch(new Request(ORIGIN + url.pathname + url.search, {
      method: request.method,
      headers,
      body: request.body,
    }));
  },
};

This edge function does three things:

  1. Redirect by country right at the edge. Homepage requests never reach origin.
  2. Block requests lacking tokens early. Origin doesn’t waste a function invocation on requests that will be rejected. Full token validation still happens at origin.
  3. Forward remaining requests. Responses from origin carry Cache-Control headers from section 4, so the edge cache layer knows which responses to store and for how long.

7. Testing key behavior

The contract between origin and edge is the cache header. So write tests to ensure it doesn’t change unintentionally:

@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void productResponseIsCacheableAtEdge() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/products/p-1")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getHeader("Cache-Control"))
            .contains("public", "s-maxage=300", "stale-while-revalidate=60");
    }

    @Test
    void cartResponseIsNeverStored() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/cart")
                .header("Authorization", "Bearer demo"))
            .andReturn();

        assertThat(result.getResponse().getHeader("Cache-Control")).isEqualTo("no-store");
    }
}

A serverless function is just a Function, so you test it like regular Java code:

class PricingFunctionsTest {

    private final Function<OrderRequest, OrderQuote> quoteOrder =
        new PricingFunctions().quoteOrder();

    @Test
    void addsVietnameseTax() {
        OrderQuote quote = quoteOrder.apply(new OrderRequest("VN", new BigDecimal("150000")));

        assertThat(quote.tax()).isEqualByComparingTo("15000");
        assertThat(quote.total()).isEqualByComparingTo("165000");
    }

    @Test
    void unknownCountryHasNoTax() {
        OrderQuote quote = quoteOrder.apply(new OrderRequest("XX", new BigDecimal("100")));

        assertThat(quote.total()).isEqualByComparingTo("100");
    }
}

8. Common mistakes and when not to use

8.1. Common mistakes

  • Caching personal data. If you forget no-store or private on user-dependent responses, the edge might serve one user’s cart to another. This is the most serious mistake.
  • Cache key doesn’t match personalization. If origin returns different content based on X-Edge-Country but edge’s cache key doesn’t account for that header, all users get the same version. Add appropriate Vary headers or configure the cache key at the edge.
  • Calling the database directly from edge. Each request from many edge points to a single database in one region. Latency won’t decrease—it rises, plus you risk exhausting connections.
  • Business logic duplicated. Pricing rules written once in JavaScript at the edge, once in Java at origin, then gradually diverge. Edge should only handle infrastructure work: routing, caching, filtering.
  • Trusting edge checks absolutely. Token checks at the edge are only for early filtering. Origin must still authenticate and authorize fully.

8.2. When NOT to use

Note: The hybrid model increases the layers you must debug, deploy, and monitor. Use it only when benefits are clearly measurable.

  • Users are concentrated in one area, close to the origin region. Edge compute brings little benefit then. A static CDN cache is often enough.
  • Your app is mostly writes or needs strong consistency, e.g., payment systems or inventory management. Most requests still must reach origin.
  • Workload runs long or is CPU-heavy, like video processing or large reports. They don’t fit edge constraints, and often suit long-running services better than functions.
  • Traffic is stable and consistently high. A long-running service may be simpler and cheaper than serverless.
  • Your team is small and lacks good observability. Bugs spread across edge, serverless, and database are hard to trace without end-to-end tracing.

9. Conclusion

The edge and serverless hybrid model doesn’t replace traditional architecture. It’s a way to divide work by distance: lightweight, read-heavy tasks needing no central data run at the edge; consistency-critical business logic runs in serverless origin near the database.

In this article, you’ve seen how a Spring Boot origin controls edge caching via Cache-Control with s-maxage and stale-while-revalidate. You’ve also written business logic as a Function with Spring Cloud Function for easy testing and platform switching, then used edge functions to redirect, block early, and forward requests.

Start with the simplest step: set correct cache headers for each endpoint and measure the impact. Only move logic to edge when your own system metrics show it’s worth it.

edge computingserverlessspring bootspring cloud functionarchitecture