cloud, Design, JavaScript, webdev

Building Resilient Microservices for Bootstrapped Apps 🏃🏼‍♀️🚵🏼

Out on the trail, your phone isn’t plugged into a wall. Every network ping drains battery. Every background process steals CPU cycles that could keep your GPS running for another hour.

In my previous post on the GPX Water Mapper, we explored visualizing water sources along your route. Now we’re going under the hood: designing a microservices architecture that respects the constraints of outdoor apps—where a dead battery isn’t just inconvenient, it’s potentially dangerous.

Imagine getting a real-time hydration alert as you approach mile 12 of your desert bike ride, calculated from your current pace, the temperature, and known water sources ahead.
All while using less battery than a typical podcast app.

What we’ll build:

  • A microservices approach that actually makes sense for small teams
    (spoiler: not everything needs to be a service – moreover, in many cases you should avoid it and run on a monolith)
  • Event-driven patterns that keep your app responsive without constant polling
  • Node.js implementations you can deploy on minimal infrastructure
  • Real-world trade-offs: when to split services and when a monolith wins

Why Microservices — Especially for Bootstrapped Teams?

Any new modern app got to face some constraints.
In our example of outdoor and sports apps face unique aspects:

  1. Intermittent connectivity — no guarantee of a live connection
  2. Tight latency & battery limits — real-time but low-power
  3. Complex data types — GPX geometry, telemetry, hydration analytics
  4. Resilience matters — one failure shouldn’t take the app down

A monolith is simpler and faster to start with — great for prototypes —
but as you scale, it becomes brittle. However, in many (many) cases, you can keep it as monolith. Only move forward if it’s a real ‘must’.

In contrast, microservices let you split your system into smaller, independently scalable units — perfect when one domain (like real-time alerts) needs to grow faster than others.

But beware: microservices also add operational overhead — service discovery, versioning, tracing, message brokers.

💡 Rule of thumb:
Start as a modular monolith.
Extract microservices only when a domain needs independent scaling or fails often.


Microservice Domains for an Outdoor App

When you eventually decompose, split along natural boundaries:

ServiceResponsibilities
Route / GPX ServiceParse, simplify, interpolate GPX files
Hydration Model ServiceCompute dehydration risk, generate alerts
Telemetry ServiceCollect location pings, pace, elevation
Alerting ServiceSend push/SMS/email alerts
Map Cache ServiceServe map tiles offline
Analytics ServiceUsage & route statistics

These domains communicate via either:

  • HTTP APIs — simpler, direct, synchronous
  • Event streams — resilient, async, ideal for real-time telemetry

Event-Driven vs Request/Response

Let’s say your user’s device sends a GPS update.

  • Request/response:
    The app calls /hydration/check and gets a hydration level back immediately.
  • Event-driven:
    The Telemetry Service publishes a location.update event;
    the Hydration Service subscribes, processes it, and may emit a hydration.alert event.
PatternProsCons
Event-drivenLoose coupling, fault-tolerant, asyncHarder to debug, need broker
HTTP syncSimpler, predictableLess resilient, tighter coupling

For real-time hydration alerts, the hybrid pattern works best — immediate response + async event pipeline for background processing.


Resilience Patterns (from Big-Scale Systems)

From lessons learned at Google and Facebook, three rules stand out:

  1. Isolate failures — one bad module shouldn’t crash the app.
  2. Use circuit breakers to prevent cascading retries.
  3. Cache everything (maps, water data, telemetry windows).

In our small systems, we can adopt lightweight versions of the same ideas:

  • Circuit breakers via opossum
  • Redis or in-memory caches
  • Local fallback: “No data? Default to safe hydration warning.”

💡 Node.js Code Patterns

1️⃣ Event Listener with NATS

// hydrationService.js
const { connect } = require("nats");

async function main() {
  const nc = await connect({ servers: "nats://localhost:4222" });
  const sub = nc.subscribe("telemetry.location");

  for await (const msg of sub) {
    const loc = JSON.parse(msg.data);
    const hydration = evaluateHydration(loc);
    if (hydration.alert) {
      nc.publish("hydration.alert", JSON.stringify({
        userId: loc.userId,
        level: hydration.level
      }));
    }
  }
}

function evaluateHydration({ lat, lng }) {
  // simulate water distance 
  const distance = Math.random() * 4000; 
  return { alert: distance > 2000, level: distance > 3000 ? "critical" : "low" };
}

main();


2️⃣ API Gateway Example

// gateway.js
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());

app.post("/location-update", async (req, res) => {
  const { userId, lat, lng } = req.body;
  await axios.post("http://telemetry-svc:3001/submit", { userId, lat, lng });
  const hydration = await axios.post("http://hydration-svc:3002/check", { userId, lat, lng });
  res.json({ hydration: hydration.data });
});

app.listen(3000, () => console.log("Gateway running on port 3000"));


3️⃣ Circuit Breaker (Protecting Downstream Services)

const CircuitBreaker = require("opossum");
const axios = require("axios");

const breaker = new CircuitBreaker(
  opts => axios.request(opts),
  { timeout: 2000, errorThresholdPercentage: 50, resetTimeout: 10000 }
);

async function callHydrationSvc(body) {
  try {
    const res = await breaker.fire({ method: "post", url: "http://hydration-svc/check", data: body });
    return res.data;
  } catch {
    return { hydration: "unknown", alert: false };
  }
}


Putting It All Together

A minimal microservice setup for hydration alerts:

Client → [API Gateway] → [Telemetry Svc]
                        ↘ publishes → telemetry.location
                            ↳ [Hydration Svc] → publishes hydration.alert
                                   ↳ [Alert Svc] → push notification / SMS

Each service is independently deployable and stateless,
with NATS or Redis Streams as a tiny event bus.


Key Takeaways

  • ✅ Start with a modular monolith, not full microservices
  • 🧩 Introduce events for real-time or fault-tolerant flows
  • 🔌 Add circuit breakers & caching early — they save your day
  • 📊 Implement observability (OpenTelemetry, logs, metrics)
  • 💬 Design for graceful degradation — network outages are normal outdoors

Microservices don’t make your system automatically better — they just change where complexity lives.
The trick is knowing when to split — and designing so you can.


Final Thought

Whether you’re mapping water stops on a 100K trail race or tracking hydration on a desert bike ride, the same principles apply:
keep each service small, resilient, and ready to fail gracefully.


Discover more from Ido Green

Subscribe to get the latest posts sent to your email.

Standard

Leave a comment