Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 094 / 100

Serverless and managed real-time: lessons from production

When you don't want to run socket servers.

Expona Admin9 min read

Serverless and managed real-time

By day 94, you've built WebSocket servers that scale horizontally, handled Kafka topics concurrently, and debugged connection storms in production. But every time you deploy, you're managing another piece of infrastructure. What if you didn't have to?

Serverless and managed real-time services let you trade operational overhead for monthly bills and reduced control. Instead of running socket servers in containers, you connect clients to AWS API Gateway WebSockets, Pusher, Ably, or Azure SignalR—platforms that handle connection management, scaling, and message routing for you. This post walks through when that trade-off makes sense and how to integrate it with Kafka.

API Gateway WebSockets: Managed connections with Lambda handlers on AWS

AWS API Gateway WebSockets is the most DIY of the managed options. You still write code—Lambda functions handle connect, disconnect, and message events—but you don't provision or scale the connection layer itself.

Here's how it works: clients connect to an API Gateway WebSocket endpoint. Gateway manages SSL termination, connection pooling, and routing. When a client sends a message, Gateway invokes a Lambda function you define. When you want to broadcast from your Kafka consumer, you call the API Gateway Management API to send messages to specific clients or all connected clients.

A typical architecture:

  • Client connects to wss://xxxxxx.execute-api.region.amazonaws.com/stage
  • Lambda function handles $connect route (store connection ID in DynamoDB)
  • Kafka consumer processes events, calls Management API to push to clients
  • Lambda handles $disconnect route (cleanup)

Here's a working example. First, your Lambda handler for $default (catch-all for messages):

typescript
import { APIGatewayWebSocketEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';

const dynamoDB = new DynamoDBClient({});

export const handler = async (
  event: APIGatewayWebSocketEvent
): Promise => {
  const connectionId = event.requestContext.connectionId;
  const routeKey = event.requestContext.routeKey;

  try {
    if (routeKey === '$connect') {
      // Store connection ID when client connects
      await dynamoDB.send(
        new PutCommand({
          TableName: 'WebSocketConnections',
          Item: {
            connectionId,
            connectedAt: new Date().toISOString(),
          },
        })
      );
      return { statusCode: 200, body: 'Connected' };
    }

    if (routeKey === '$disconnect') {
      // Clean up on disconnect
      const { DeleteCommand } = await import('@aws-sdk/lib-dynamodb');
      // Deletion logic here
      return { statusCode: 200, body: 'Disconnected' };
    }

    // Handle custom message routes
    console.log(`Message from ${connectionId}: ${event.body}`);
    return { statusCode: 200, body: 'Message received' };
  } catch (error) {
    console.error('Handler error:', error);
    return { statusCode: 500, body: JSON.stringify({ error: 'Internal error' }) };
  }
};

Now your Kafka consumer broadcasts to all connected clients:

typescript
import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
import { Consumer } from 'kafkajs';

const apiGateway = new ApiGatewayManagementApiClient({
  endpoint: 'https://xxxxxx.execute-api.region.amazonaws.com/stage',
});

const dynamoDB = new DynamoDBClient({});

export async function broadcastKafkaEvent(message: any) {
  // Get all connected clients
  const result = await dynamoDB.send(
    new ScanCommand({
      TableName: 'WebSocketConnections',
    })
  );

  const connections = result.Items || [];

  // Send to each connection
  const promises = connections.map((conn: any) =>
    apiGateway
      .send(
        new PostToConnectionCommand({
          ConnectionId: conn.connectionId,
          Data: JSON.stringify(message),
        })
      )
      .catch((err) => {
        // Handle stale connections (client disconnected before Lambda invoked)
        if (err.name === 'GoneException') {
          console.log(`Removing stale connection ${conn.connectionId}`);
          // Delete from DynamoDB
        }
      })
  );

  await Promise.all(promises);
}

// In your Kafka consumer
export const kafkaConsumer = async (consumer: Consumer) => {
  await consumer.subscribe({ topic: 'orders', fromBeginning: false });

  await consumer.run({
    eachMessage: async ({ topic, partition, message }) => {
      const event = JSON.parse(message.value!.toString());
      await broadcastKafkaEvent({
        type: 'ORDER_UPDATE',
        payload: event,
        timestamp: new Date().toISOString(),
      });
    },
  });
};

Trade-offs: You still write Lambdas (so you own the logic), but Gateway handles the hard parts—connection limits, SSL, routing. Costs scale with connection-minutes and data transferred. For spiky traffic (10 concurrent to 10,000 in seconds), this beats maintaining a cluster.

Managed services: Pusher, Ably and Azure SignalR handle scale for you

Services like Pusher, Ably, and Azure SignalR go further. You don't write connection handlers. You publish to channels, they route to subscribers. This is the highest abstraction.

With Pusher, your architecture is:

  • Client subscribes to a channel (e.g., "order-updates")
  • Kafka consumer publishes events to Pusher REST API
  • Pusher fans out to all subscribers

Here's Pusher in action:

typescript
import Pusher from 'pusher';
import { Consumer } from 'kafkajs';

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: 'ap2',
});

export const kafkaConsumer = async (consumer: Consumer) => {
  await consumer.subscribe({ topic: 'orders' });

  await consumer.run({
    eachMessage: async ({ message }) => {
      const event = JSON.parse(message.value!.toString());

      // Publish to Pusher channel
      await pusher.trigger('order-updates', 'new-order', {
        orderId: event.id,
        status: event.status,
        amount: event.amount,
        timestamp: new Date().toISOString(),
      });

      console.log(`Published order ${event.id} to Pusher`);
    },
  });
};

On the client side (React):

typescript
import Pusher from 'pusher-js';
import { useEffect, useState } from 'react';

export function OrderDashboard() {
  const [orders, setOrders] = useState([]);

  useEffect(() => {
    const pusher = new Pusher(process.env.REACT_APP_PUSHER_KEY!, {
      cluster: 'ap2',
    });

    const channel = pusher.subscribe('order-updates');

    channel.bind('new-order', (data) => {
      setOrders((prev) => [data, ...prev]);
    });

    return () => {
      channel.unbind_all();
      pusher.disconnect();
    };
  }, []);

  return (
    <div>
      <h3>Live Orders</h3>
      <ul>
        {orders.map((order) => (
          <li>
            {order.orderId}: {order.status} — LKR {order.amount}
          </li>
        ))}
      </ul>
    </div>
  );
}

Ably and Azure SignalR follow the same pattern but add features: Ably includes message history and presence (who's online), Azure SignalR integrates tightly with the Azure ecosystem. Pick based on your needs and cloud lock-in tolerance.

Why this works: No infrastructure to manage. No Lambda cold starts for WebSocket handlers. Automatic scaling. Built-in features like presence, history, and encryption.

Cost model: Pay per connection-minute or per message. At high scale (millions of messages/day), this gets expensive. At low-to-medium scale (thousands of users), it's often cheaper than running servers.

Trade-offs: Less ops, more cost per message, less control

Moving from self-hosted WebSocket servers to managed services shifts burden from your ops team to your wallet and your flexibility.

Dimension Self-Hosted Socket.io AWS API Gateway WS Pusher / Ably / SignalR
Setup time Hours (server config, SSL, monitoring) Minutes (Lambda + API Gateway) Minutes (SDK + auth key)
Scaling work Yours (load balancer, Redis adapter, cluster config) Automatic (but manage Lambda concurrency) Automatic (handled by service)
Cost at 100k messages/day ~USD 50–100 (t3.small server + data transfer) ~USD 50–200 (Lambdas + API Gateway) ~USD 100–300 (message + connection fees)
Cost at 10M messages/day ~USD 200–500 (cluster of servers) ~USD 1000+ (Lambda execution) ~USD 2000+ (per-message pricing kills you)
Customization Full control (middleware, rooms, adapters) Limited (Lambda context only) Very limited (vendor's API)
Vendor lock-in None AWS-specific High (migrate hard)

The cost crossover is real. At small scale, managed services win. At large scale, self-hosted wins. Most teams land in the middle and choose based on team size and risk tolerance.

Control loss: With Pusher, you can't inspect network traffic, can't implement custom routing logic, can't optimize for your specific use case. You're inside their box. That's fine for 80% of apps; it's painful for the other 20%.

Good fit: Spiky traffic and small teams

When does managed real-time shine? Two scenarios:

Spiky traffic: You run a poll app that gets 10 users most days, then 100,000 during a live election. Self-hosting means paying for peak capacity all year. Managed services charge only for what you use. During off-peak, your bill is near-zero. During spike, you scale instantly with no code changes.

Small teams: You have two backend engineers and zero DevOps. Kafka is already complex enough. Adding WebSocket server operations, monitoring, failover, and SSL rotation is one too many things. Managed services buy you focus.

Real example from my own work: a ride-hailing app's tracking service. Drivers broadcast location every 3 seconds to ~5,000 concurrent users. Peak scale is sharp (morning/evening commute). We used AWS API Gateway WebSockets because:

  • Lambda auto-scales with concurrent connections
  • We only pay during commute hours
  • DynamoDB stores minimal state (just connection IDs)
  • Kafka consumer sends events to API Gateway without managing sockets directly

Cost was ~USD 120/month. A self-hosted solution would cost USD 200+. With a two-person team, the operational savings justified the slightly higher per-message cost.

Common mistakes

Mistake 1: Publishing Kafka events synchronously from Lambda. Your Kafka consumer runs in a Lambda. It publishes to Pusher. If Pusher is slow, your Lambda times out. Solution: use SQS or SNS to decouple. Kafka consumer publishes to SQS; a separate Lambda processes the queue and publishes to Pusher.

Mistake 2: Storing all connection state in DynamoDB without TTL. Clients disconnect uncleanly. Connection records pile up. Your DynamoDB bill climbs. Solution: set TTL on connection records (e.g., 24 hours). Clean up stale connections when API Gateway returns GoneException.

Mistake 3: Choosing Pusher for cost reasons at high scale. You're processing 10M events/day. Pusher charges USD 0.50 per million messages. That's USD 5 per day just for Pusher. Over a year, USD 1,825. A small EC2 instance costs USD 50–100/month. Do the math before committing.

Mistake 4: Forgetting authentication. Your Pusher channel is public. Anyone can subscribe. Solution: use private channels (requires auth token) and presence channels (requires further verification). Never expose secrets in client code.

Mistake 5: Not handling backpressure from the managed service. Kafka consumer publishes 1,000 events at once to API Gateway. Rate limiter kicks in. Events drop silently. Solution: batch publishes and implement exponential backoff.

Key takeaways

  • Serverless and managed real-time services move operational burden to cloud providers. You gain speed, lose control.
  • API Gateway WebSockets is the middle ground: you write Lambda code, but connection management is automatic.
  • Pusher, Ably, and Azure SignalR abstract away all socket logic. Best for teams that want to ship fast.
  • Cost crossover exists: managed services win at small scale and spiky traffic; self-hosted wins at sustained high scale.
  • Decouple Kafka consumers from publish operations using queues to avoid Lambda timeouts.
  • Always set TTL on connection records and handle stale connections.
  • Authenticate channels; never trust client-side trust.
  • Batch publishes and implement backoff to handle rate limits from managed services.
  • For a two-person team with variable load, the peace of mind from managed services often outweighs the per-message cost.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles