Building Scalable Real-Time Applications: Fastify, Cloud Run, PlanetScale, & WebSockets
Modern web applications demand responsiveness, scalability, and an efficient development workflow. Integrating high-performance backend frameworks, serverless deployment, a horizontally scalable database, and real-time communication is crucial. This article demonstrates how to weave together Fastify, Google Cloud Run, PlanetScale, and WebSockets within a single TypeScript application, complete with best practices for testing and debugging.
The Integrated Architecture: A Synergy of Technologies
Our goal is a highly performant, real-time application capable of handling dynamic user interactions and scaling on demand. Here's how our chosen stack contributes:
- Fastify: A performant and low-overhead web framework for Node.js, ideal for building APIs and WebSocket servers in TypeScript.
- PlanetScale: A serverless, MySQL-compatible database offering horizontal scaling, branching, and a developer-friendly experience, powered by Vitess.
- WebSockets: Enabling persistent, bi-directional communication for real-time features like chat or live updates.
- Google Cloud Run: A fully managed serverless platform for containerized applications, offering auto-scaling, rapid deployment, and a pay-per-use model.
- Testing & Debugging: Essential practices for maintaining code quality and ensuring application reliability.
Let's dive into the implementation.
Fastify: The High-Performance Core
Fastify provides the foundation for our API and WebSocket server. Its plugin-based architecture and robust TypeScript support make it an excellent choice.
First, set up a basic Fastify project:
// src/app.ts
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
export const buildApp = async () => {
const fastify = Fastify({
logger: true,
});
await fastify.register(fastifyWebsocket);
fastify.get('/', async (request, reply) => {
return { hello: 'world' };
});
return fastify;
};
// src/server.ts
import { buildApp } from './app';
const start = async () => {
const fastify = await buildApp();
try {
await fastify.listen({ port: parseInt(process.env.PORT || '3000') });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
This basic setup registers the @fastify/websocket plugin, which we'll use shortly for real-time capabilities.
PlanetScale: Scalable Database Foundation
PlanetScale's branching workflow and horizontal scalability are perfect for evolving applications. We'll use Prisma as our ORM for a type-safe and efficient interaction with the database. First, connect to your PlanetScale database (which is MySQL-compatible) and set up your schema.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Message {
id Int @id @default(autoincrement())
content String @db.Text
timestamp DateTime @default(now())
}
Now, integrate Prisma into Fastify. A common pattern is to register Prisma as a Fastify plugin.
// src/plugins/prisma.ts
import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}
export default fp(async (fastify) => {
const prisma = new PrismaClient();
await prisma.$connect();
fastify.decorate('prisma', prisma);
fastify.addHook('onClose', async (server) => {
await server.prisma.$disconnect();
});
});
Register this plugin in src/app.ts:
// src/app.ts (snippet)
import prismaPlugin from './plugins/prisma';
export const buildApp = async () => {
// ... (previous setup)
await fastify.register(prismaPlugin);
// Example route using prisma
fastify.get('/messages', async (request, reply) => {
const messages = await fastify.prisma.message.findMany();
return messages;
});
// ...
};
WebSockets: Real-Time Communication
With @fastify/websocket registered, adding real-time capabilities is straightforward. Let's create a simple chat functionality.
// src/app.ts (snippet)
// ...
interface WebSocketConnection extends WebSocket {
id: string;
}
const connections = new Set<WebSocketConnection>();
export const buildApp = async () => {
const fastify = Fastify({ logger: true });
await fastify.register(fastifyWebsocket);
await fastify.register(prismaPlugin);
// WebSocket route
fastify.websocket('/ws', { handler: async (connection /* SocketStream */, req) => {
const ws = connection.socket as WebSocketConnection;
ws.id = Math.random().toString(36).substring(7); // Simple ID for demonstration
connections.add(ws);
fastify.log.info(`Client ${ws.id} connected`);
ws.onmessage = async (event) => {
const message = JSON.parse(event.data.toString());
fastify.log.info(`Received from ${ws.id}: ${message.content}`);
// Save message to PlanetScale
await fastify.prisma.message.create({ data: { content: message.content } });
// Broadcast to all connected clients
const messageWithTimestamp = { ...message, timestamp: new Date().toISOString(), from: ws.id };
connections.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(messageWithTimestamp));
}
});
};
ws.onclose = () => {
connections.delete(ws);
fastify.log.info(`Client ${ws.id} disconnected`);
};
ws.onerror = (error) => {
fastify.log.error(`WebSocket error for ${ws.id}:`, error);
};
}});
// ... other routes
return fastify;
};
Note on Cloud Run: While this approach works for basic examples, for highly scaled WebSocket applications on Cloud Run (which is stateless), you'd typically introduce a Pub/Sub or Redis layer to broadcast messages across multiple container instances effectively.
Testing & Debugging: Ensuring Reliability
Robust applications require thorough testing and efficient debugging.
Testing with Vitest
vitest provides a fast and modern testing experience for TypeScript projects. Let's test our Fastify API and WebSocket handlers.
// test/app.test.ts
import { test, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '../src/app';
import { FastifyInstance } from 'fastify';
import WebSocket from 'ws'; // Node.js WebSocket client
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp();
await app.listen({ port: 0 }); // Listen on a random available port
});
afterAll(async () => {
await app.close();
});
test('GET / returns { hello: "world" }', async () => {
const response = await app.inject({
method: 'GET',
url: '/',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ hello: 'world' });
});
test('WebSocket /ws receives and broadcasts messages', async () => {
const address = app.server.address();
if (typeof address === 'string' || !address) {
throw new Error('Server address not available');
}
const wsUrl = `ws://localhost:${address.port}/ws`;
const client1 = new WebSocket(wsUrl);
const client2 = new WebSocket(wsUrl);
await new Promise<void>((resolve) => {
client1.onopen = () => resolve();
});
await new Promise<void>((resolve) => {
client2.onopen = () => resolve();
});
const messages: string[] = [];
client2.onmessage = (event) => {
messages.push(event.data.toString());
};
const testMessage = { content: 'Hello, real-time world!' };
client1.send(JSON.stringify(testMessage));
// Give some time for message propagation and database persistence
await new Promise((resolve) => setTimeout(resolve, 100));
expect(messages.length).toBe(1);
const receivedMessage = JSON.parse(messages[0]);
expect(receivedMessage.content).toEqual(testMessage.content);
expect(receivedMessage).toHaveProperty('timestamp');
expect(receivedMessage).toHaveProperty('from');
client1.close();
client2.close();
});
This example shows how to test both HTTP routes using Fastify's inject method and WebSocket interactions using a Node.js WebSocket client.
Debugging
- Local Debugging (VS Code): Use VS Code's built-in debugger. Create a
launch.jsonconfiguration to run yoursrc/server.tswith source maps enabled (e.g., viats-node-dev --inspect-brk src/server.ts). - Cloud Run Debugging: Leverage Cloud Logging and Error Reporting. Fastify's logger integrates well with Cloud Logging. For more advanced debugging, consider enabling Cloud Trace or using Google Cloud's Operations Suite features.
Google Cloud Run: Serverless Deployment
Cloud Run is perfect for deploying our containerized Fastify application. First, create a Dockerfile.
# Dockerfile
# Use a lightweight Node.js image
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package.json and install dependencies
COPY package*.json ./
RUN npm install --omit=dev
# Copy source code
COPY . .
# Build TypeScript to JavaScript
RUN npm run build
# Expose the port your app listens on
EXPOSE 8080
# Start the application
CMD ["node", "dist/server.js"]
Build and deploy to Cloud Run using the gcloud CLI:
gcloud builds submit --tag gcr.io/[PROJECT-ID]/realtime-app
gcloud run deploy realtime-app \
--image gcr.io/[PROJECT-ID]/realtime-app \
--platform managed \
--region us-central1 \
--allow-unauthenticated \
--set-env-vars DATABASE_URL="[YOUR-PLANETSCALE-CONNECTION-STRING]" \
--add-cloudsql-instances [YOUR-CLOUD-SQL-CONNECTION-NAME] # If using Cloud SQL Proxy
Remember to configure your DATABASE_URL as an environment variable in Cloud Run, pointing to your PlanetScale connection string. If your PlanetScale database requires a Cloud SQL Auth proxy (e.g., for VPC peering), you'd specify --add-cloudsql-instances. For standard connections, simply setting DATABASE_URL is sufficient.
Crucially, Cloud Run fully supports WebSockets, making it a viable serverless option for real-time applications.
Conclusion
By carefully integrating Fastify, PlanetScale, WebSockets, and Google Cloud Run, we've outlined a robust, scalable, and developer-friendly stack for modern real-time applications. Fastify provides the performance, PlanetScale the scalable database, WebSockets the immediacy, and Cloud Run the serverless operational ease. Combined with diligent testing and debugging practices, this architecture empowers developers to build and deploy complex, high-performance web services with confidence.
This integrated approach not only optimizes for speed and scalability but also streamlines development and operations, allowing your team to focus on delivering features rather than managing infrastructure. Explore these technologies further to unlock their full potential in your next project.