Skip to main content

๐ŸŒ Weekend Project: Build a Full-Stack Social Media App

This is the weekend everything from Week 10 stops being separate lessons and becomes one running product. You'll build Pulse โ€” a small social app with a React client and an Express + MongoDB API โ€” and wire together the whole week: a token-aware API client talking across a CORS boundary, JWT auth from Week 9, a posts feed with CRUD, image upload that hands heavy work to a background job, Socket.IO notifications that arrive without a refresh, and Redis caching on the hottest endpoint. This is an integration capstone: the win isn't any one feature, it's watching the pieces connect.

Week 10 · Weekend Project · Full-Stack Integration Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Assemble a two-tier monorepo โ€” a Vite/React client/ and an Express server/ โ€” that run and deploy independently but speak one API contract
  • Cross the browser's origin boundary safely with a configured CORS policy and a single API client module that attaches the JWT to every request
  • Protect write routes with JWT authentication middleware and identify the current user from the verified token
  • Ship a posts feed with full CRUD, then make its list endpoint fast with a Redis cache-aside layer and correct invalidation on write
  • Accept an image upload with Multer and offload the slow resize to a BullMQ background job so the request returns immediately
  • Push real-time notifications with Socket.IO to the right user when someone likes or comments on their post
  • Reason about a system as connected parts โ€” trace one request from a click through client, API, cache, queue, worker, and socket

Estimated Time: 8โ€“12 hours across the weekend

Project: A runnable full-stack social app โ€” React client + Express/MongoDB API โ€” with JWT auth, a cached and paginated feed, background image processing, and live Socket.IO notifications.

In This Project

The Goal

Build something that behaves like a real social product, not a tutorial demo. You open Pulse, register, and land on a feed. You write a post, optionally attach a photo, and hit publish โ€” the post appears instantly while its image quietly finishes processing in the background. You scroll a feed that loads fast even under repeated refreshes because it's cached. You like someone's post and, on their screen, a notification badge ticks up in real time โ€” no refresh, no polling. That whole experience is Week 10's toolbox working together.

Every lesson this week was a single tool. This weekend you learn the harder skill: composition. The emphasis below is deliberately on how the pieces connect โ€” the request flows, the boundaries, the contracts โ€” rather than re-typing every line you already wrote in the week's lessons. When a stage needs a pattern you built earlier (a Mongoose model, a CORS block, a Socket.IO handler), we reference it and show the seam where it plugs in.

๐Ÿ“– What "integration" really tests

A feature in isolation is easy; a feature that must not break five others is the job. Caching is trivial until a write has to invalidate the cache. Uploads are easy until a 4 MB resize can't be allowed to block the response. Real-time is simple until you must notify one specific user and not the whole room. The value this weekend is in those joints โ€” the places where two systems meet and have to agree.

System Architecture

Before any code, hold the whole system in your head. Two apps you build (the React client and the Express API) sit alongside three backing services (MongoDB, Redis, and a queue worker). The client only ever talks to the API; the API orchestrates everything behind it.

graph TD subgraph Browser UI["React client
Vite dev server"] WS["Socket.IO client"] end subgraph Server["Express API server"] API["REST routes
+ JWT middleware"] IO["Socket.IO server"] end Worker["BullMQ worker
image processing"] DB[("MongoDB")] Cache[("Redis
cache + queue")] UI -->|"HTTP + Bearer token, CORS"| API API -->|"read and write"| DB API -->|"cache-aside"| Cache API -->|"enqueue resize job"| Cache Cache -->|"job payload"| Worker Worker -->|"save processed image"| DB API -->|"emit to a user room"| IO IO -->|"push notification"| WS

Notice that Redis plays two roles โ€” it's both the cache and the queue's backing store โ€” which is common and efficient. Notice too that the worker is its own process: it shares the database and Redis but runs separately, so a spike in image jobs never slows the API that's serving the feed. Those two observations are the heart of the design.

๐Ÿ’ก One contract, two deployables

The client and server are separate apps in one repo (a monorepo). They can be developed together, versioned together, and deployed to different hosts โ€” the client to a static/CDN host, the API to a Node host. The only thing binding them is the API contract: the routes, the JSON shapes, and the auth header. Keep that contract stable and either side can change freely.

Prerequisites

This is the Week 10 capstone, so it leans on the whole week plus Week 9's auth. You don't need to have finished every lesson perfectly, but you should recognize each of these โ€” the stages assume the pattern and show only the integration seam:

  • JWT auth (Week 9) โ€” hashing passwords, signing a token on login, and verifying it in middleware
  • CORS โ€” why a browser blocks cross-origin requests and how the cors middleware opens a controlled door
  • API integration patterns โ€” a single client module, base URL, interceptors, and consistent error handling
  • File upload & image processing โ€” multer to accept a file, sharp to resize it, optional cloud storage
  • Redis caching โ€” the cache-aside pattern, TTLs, and invalidation on write
  • Queues โ€” BullMQ producers and workers for slow background work
  • Socket.IO โ€” rooms, emitting to a specific client, and authenticating a socket connection
  • Express + Mongoose โ€” routers, controllers, models, and centralized error handling (Week 8)

You'll need Node.js 18+, a MongoDB (local or Atlas), and a Redis instance. The fastest way to get Mongo and Redis locally is Docker:

# Two throwaway containers โ€” perfect for a weekend build
docker run -d --name pulse-mongo -p 27017:27017 mongo:7
docker run -d --name pulse-redis -p 6379:6379 redis:7

โš ๏ธ This is a build, not a copy-paste

The point of a capstone is reconstruction. Where a stage references a pattern from earlier in the week, go pull your own working code from that lesson and adapt it. The seams shown here (how auth feeds the socket, how a write busts the cache) are the new material; the individual pieces you've already written once.

Required Features Checklist

These are the non-negotiables โ€” each one is a Week 10 skill, and each must actually connect to the others. Tick them off as they come online.

โœ… Must-have features

  • โ˜ Monorepo โ€” a Vite/React client/ and an Express server/, each with its own package.json
  • โ˜ CORS wired correctly โ€” the API accepts requests from the client origin and allows credentials/headers it needs
  • โ˜ One API client module on the front end that sets the base URL and attaches the JWT to every request
  • โ˜ JWT auth โ€” register, login, and middleware that protects write routes and exposes req.userId
  • โ˜ Posts feed with full CRUD โ€” create, read (paginated list + one), update, delete; likes and comments
  • โ˜ Redis cache on the feed list โ€” cache-aside read, invalidated on every write to posts
  • โ˜ Image upload via Multer, with the resize handed to a BullMQ background job (request never blocks on it)
  • โ˜ Real-time notifications via Socket.IO โ€” a like/comment pushes a live notification to the post's author only
  • โ˜ Centralized error handling and honest status codes across the API (200/201/204, 400/401/404)

Project Structure

A monorepo with two clearly separated apps. The server/ keeps the role-based layout from Week 8 and adds folders for the new concerns โ€” cache/, queue/, and realtime/. The client/ is the Vite/React shape from Week 5 with an api/ layer as the single door to the backend.

pulse/
โ”œโ”€โ”€ package.json            <-- root: dev script runs both apps
โ”œโ”€โ”€ docker-compose.yml      <-- optional: mongo + redis
โ”œโ”€โ”€ client/                 <-- Vite + React front end
โ”‚   โ”œโ”€โ”€ package.json
โ”‚   โ”œโ”€โ”€ .env                <-- VITE_API_URL=http://localhost:4000
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ”œโ”€โ”€ main.jsx
โ”‚       โ”œโ”€โ”€ api/
โ”‚       โ”‚   โ”œโ”€โ”€ client.js       <-- axios instance + JWT interceptor
โ”‚       โ”‚   โ””โ”€โ”€ posts.js        <-- feed/create/like/comment calls
โ”‚       โ”œโ”€โ”€ context/AuthContext.jsx
โ”‚       โ”œโ”€โ”€ hooks/useSocket.js  <-- connects Socket.IO with the token
โ”‚       โ”œโ”€โ”€ pages/{Login,Feed,Profile}.jsx
โ”‚       โ””โ”€โ”€ components/{PostCard,NewPost,NotificationsBell}.jsx
โ””โ”€โ”€ server/                 <-- Express API + worker
    โ”œโ”€โ”€ package.json
    โ”œโ”€โ”€ .env                <-- MONGO_URI, REDIS_URL, JWT_SECRET, CLIENT_URL
    โ””โ”€โ”€ src/
        โ”œโ”€โ”€ server.js           <-- connect DB+Redis, create http+socket, listen
        โ”œโ”€โ”€ app.js              <-- express app: cors, json, routes, errors
        โ”œโ”€โ”€ config/{db,redis}.js
        โ”œโ”€โ”€ models/{User,Post}.js
        โ”œโ”€โ”€ middleware/{auth,upload,errorHandler}.js
        โ”œโ”€โ”€ routes/{auth,posts}.js
        โ”œโ”€โ”€ controllers/{authController,postController}.js
        โ”œโ”€โ”€ cache/postCache.js  <-- get/set/invalidate helpers
        โ”œโ”€โ”€ queue/imageQueue.js  <-- BullMQ producer (enqueue jobs)
        โ”œโ”€โ”€ worker.js           <-- BullMQ consumer (separate process)
        โ””โ”€โ”€ realtime/io.js      <-- Socket.IO setup + notifyUser()

๐Ÿ’ก Why the worker is its own file and its own process

server.js serves HTTP and sockets; worker.js pulls jobs off the queue and processes images. They import the same models and Redis config but run as two node processes. That separation is what lets you scale them independently later โ€” three workers and one API, or the reverse โ€” and it means a stampede of uploads can't starve the request that's loading someone's feed.

Stage 1 โ€” Scaffold the Monorepo & CORS

Create the two apps and get them talking across the origin boundary โ€” the very first integration problem. The client runs on Vite's port (5173), the API on 4000. Those are different origins, so the browser will block the API call unless the server explicitly allows it.

mkdir pulse && cd pulse

# Front end
npm create vite@latest client -- --template react
cd client && npm install && npm install axios socket.io-client && cd ..

# Back end
mkdir server && cd server && npm init -y
npm install express cors mongoose dotenv bcrypt jsonwebtoken \
  multer sharp ioredis bullmq socket.io
npm install --save-dev nodemon
cd ..

The API's job in this stage is to build the Express app with a tight CORS policy. Don't reflexively allow every origin โ€” name the client origin from an env var, and only allow it. This is the exact pattern from the CORS lesson, dropped into the app factory:

// server/src/app.js โ€” build the app (no listen here)
const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/auth');
const postRoutes = require('./routes/posts');
const { notFound, errorHandler } = require('./middleware/errorHandler');

function createApp() {
  const app = express();

  // CORS: allow ONLY the client origin, and the Authorization header.
  app.use(cors({
    origin: process.env.CLIENT_URL,          // e.g. http://localhost:5173
    methods: ['GET', 'POST', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
  }));

  app.use(express.json());
  app.use('/uploads', express.static('uploads')); // serve processed images

  app.use('/api/auth', authRoutes);
  app.use('/api/posts', postRoutes);

  app.use(notFound);       // 404 for unmatched routes
  app.use(errorHandler);   // the one 4-arg handler, mounted last
  return app;
}

module.exports = createApp;

โš ๏ธ The two origins must agree in two places

CORS is a handshake with two sides. The server must list the client origin in cors({ origin }), and โ€” once you add real-time โ€” the Socket.IO server needs its own matching cors block (Stage 6), because the WebSocket handshake is a separate HTTP request. Get one and forget the other and you'll see requests work but sockets silently fail to connect.

Stage 2 โ€” Auth & the API Client

Auth is the spine everything else hangs on. You already built the pieces in Week 9 โ€” hash on register, sign a JWT on login, verify it in middleware. Here we focus on the seam: how the client stores that token and re-attaches it to every request without you remembering to.

The verify middleware (server)

One middleware turns a valid Authorization: Bearer <token> header into req.userId. Every write route mounts it; every controller downstream can trust that id.

// server/src/middleware/auth.js
const jwt = require('jsonwebtoken');

module.exports = function requireAuth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'Authentication required' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = payload.sub;   // the user id we signed at login
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
};

The API client (front end) โ€” one door, one interceptor

This is the single most important front-end file for integration. Instead of scattering fetch calls and copy-pasting the token everywhere, you create one axios instance with the base URL baked in and an interceptor that attaches the JWT automatically. Every feature calls through this door.

// client/src/api/client.js
import axios from 'axios';

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL, // http://localhost:4000
});

// Attach the token to every outgoing request.
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Normalize errors and auto-logout on a 401.
api.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.assign('/login');
    }
    return Promise.reject(err);
  }
);

With that in place a feature module is tiny โ€” it never thinks about tokens or base URLs again:

// client/src/api/posts.js
import { api } from './client';

export const getFeed = (page = 1) =>
  api.get(`/api/posts?page=${page}`).then((r) => r.data);

export const createPost = (formData) =>
  api.post('/api/posts', formData).then((r) => r.data); // FormData โ†’ multipart

export const likePost = (id) =>
  api.post(`/api/posts/${id}/like`).then((r) => r.data);

๐Ÿ“– Why the interceptor beats passing the token around

Threading the token through every component and every call is the prop-drilling of networking โ€” tedious and easy to get wrong. The request interceptor makes authentication a property of the client, not of each call site. Log in once (write the token to localStorage), and every subsequent request is authenticated automatically; log out (clear it) and they aren't. One place owns the rule.

Stage 3 โ€” The Posts Feed (CRUD)

Now the core resource. A Post belongs to a user, carries text and an optional image, and tracks likes and comments โ€” the same Mongoose modeling you did in Week 8, so here's just the shape and the seams that matter for later stages (the image field the worker fills in, the author the socket notifies).

// server/src/models/Post.js
const mongoose = require('mongoose');

const postSchema = new mongoose.Schema({
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  text:   { type: String, required: true, trim: true, maxlength: 500 },
  // Filled in by the background worker once the resize finishes:
  image:  { url: String, status: { type: String, default: 'none' } }, // none|processing|ready
  likes:  [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
  comments: [{
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
    text:   { type: String, required: true, maxlength: 300 },
    createdAt: { type: Date, default: Date.now },
  }],
}, { timestamps: true });

module.exports = mongoose.model('Post', postSchema);

The route table maps REST verbs to controllers, with the auth middleware guarding every write. Reads are public here; locking them down is a stretch goal.

// server/src/routes/posts.js
const router = require('express').Router();
const c = require('../controllers/postController');
const requireAuth = require('../middleware/auth');
const upload = require('../middleware/upload'); // Multer (Stage 5)

router.get('/', c.list);                                   // GET  /api/posts (cached)
router.get('/:id', c.getOne);                              // GET  /api/posts/:id
router.post('/', requireAuth, upload.single('image'), c.create); // POST (multipart)
router.patch('/:id', requireAuth, c.update);               // PATCH /api/posts/:id
router.delete('/:id', requireAuth, c.remove);              // DELETE /api/posts/:id
router.post('/:id/like', requireAuth, c.like);             // POST like โ†’ notifies author
router.post('/:id/comments', requireAuth, c.comment);      // POST comment โ†’ notifies author

module.exports = router;

โœ… The feed route map

Verb + PathActionTouches
GET /api/postsPaginated feedRedis cache โ†’ Mongo
POST /api/postsCreate (with image)Mongo, queue, cache bust
PATCH /api/posts/:idEdit own postMongo, cache bust
DELETE /api/posts/:idDelete own postMongo, cache bust
POST /api/posts/:id/likeLikeMongo, socket notify
POST /api/posts/:id/commentsCommentMongo, socket notify

The "Touches" column is the integration map in miniature: notice how many routes have to bust the cache and how the interaction routes notify over the socket. Those cross-cutting concerns are exactly what the next three stages wire in โ€” and why doing them after plain CRUD works keeps each addition testable.

Stage 4 โ€” Redis Cache on the Feed

The feed is your hottest endpoint โ€” hit on every page load and refresh โ€” and it runs the same Mongo query over and over. This is the textbook case for cache-aside: check Redis first; on a miss, query Mongo and store the result with a short TTL; return. The payoff is real only if you also handle the hard half โ€” invalidation โ€” so a new or edited post never serves stale.

sequenceDiagram participant C as Client participant API as Feed controller participant R as Redis participant DB as MongoDB C->>API: GET /api/posts page 1 API->>R: GET feed key alt cache hit R-->>API: cached JSON API-->>C: fast response from cache else cache miss R-->>API: nothing found API->>DB: query newest posts DB-->>API: post documents API->>R: SET feed key with short TTL API-->>C: response and now warm end

Keep the cache logic in one small helper so the controller stays readable and the key scheme lives in exactly one place:

// server/src/cache/postCache.js
const redis = require('../config/redis'); // an ioredis client
const TTL_SECONDS = 60;

const feedKey = (page) => `feed:page:${page}`;

exports.getFeed = async (page) => {
  const hit = await redis.get(feedKey(page));
  return hit ? JSON.parse(hit) : null;
};

exports.setFeed = (page, data) =>
  redis.set(feedKey(page), JSON.stringify(data), 'EX', TTL_SECONDS);

// Any write to posts calls this. Wipe every feed page in one shot.
exports.invalidateFeed = async () => {
  const keys = await redis.keys('feed:page:*');
  if (keys.length) await redis.del(keys);
};

The controller becomes a clean cache-aside read, and โ€” critically โ€” every write calls invalidateFeed():

// server/src/controllers/postController.js (excerpt)
const Post = require('../models/Post');
const cache = require('../cache/postCache');
const asyncHandler = require('../middleware/asyncHandler');

exports.list = asyncHandler(async (req, res) => {
  const page = Math.max(1, parseInt(req.query.page, 10) || 1);

  const cached = await cache.getFeed(page);       // 1. try cache
  if (cached) return res.json({ ...cached, cached: true });

  const limit = 10;                               // 2. miss โ†’ hit Mongo
  const posts = await Post.find()
    .sort({ createdAt: -1 })
    .skip((page - 1) * limit).limit(limit)
    .populate('author', 'username avatar');

  const payload = { data: posts, page };
  await cache.setFeed(page, payload);             // 3. warm the cache
  res.json({ ...payload, cached: false });
});

exports.create = asyncHandler(async (req, res) => {
  const post = await Post.create({ author: req.userId, text: req.body.text });
  await cache.invalidateFeed();                   // write โ‡’ bust the cache
  // ...enqueue image job (Stage 5) then respond 201
  res.status(201).json({ data: post });
});

โš ๏ธ A cache without invalidation is a bug generator

The dangerous failure mode isn't a slow feed โ€” it's a wrong one. If create, update, delete, like, and comment don't all invalidate, users see a feed frozen up to a minute behind reality, and they'll swear the app is broken. The rule from the caching lesson holds: every write path that changes cached data must bust that data. When in doubt, invalidate โ€” a needless cache miss is cheap; stale data is not.

The cached: true|false flag in the response is a deliberate teaching aid โ€” hit the feed twice and watch it flip, then create a post and watch it flip back. That's your invalidation working, made visible.

Stage 5 โ€” Image Upload + Background Job

Uploads are where the "do the slow thing later" lesson pays off. Resizing an image with sharp can take hundreds of milliseconds โ€” far too long to make the user wait inside the request. So the request does only the fast part (accept the file, enqueue a job, respond) and a separate worker does the slow part. The post is created immediately with image.status: 'processing', and the worker flips it to 'ready' when done.

graph LR U["Upload request"] --> M["Multer
saves temp file"] M --> Ctrl["Controller
create post, status processing"] Ctrl --> Q["Enqueue resize job"] Ctrl --> Resp["Respond 201 right away"] Q --> W["Worker
sharp resize"] W --> Save["Update post
image url + ready"]

Multer, configured as reusable middleware, accepts the file and enforces limits at the edge:

// server/src/middleware/upload.js
const multer = require('multer');

module.exports = multer({
  storage: multer.diskStorage({
    destination: 'uploads/tmp',
    filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
  }),
  limits: { fileSize: 5 * 1024 * 1024 },   // 5 MB cap
  fileFilter: (req, file, cb) =>
    cb(null, /^image\/(png|jpe?g|webp)$/.test(file.mimetype)),
});

The producer is a BullMQ queue โ€” the create controller adds a job and moves on. It never waits for the resize:

// server/src/queue/imageQueue.js
const { Queue } = require('bullmq');
const connection = { url: process.env.REDIS_URL };

const imageQueue = new Queue('image-processing', { connection });

// Called from the create controller after multer runs:
exports.enqueueResize = (postId, tmpPath) =>
  imageQueue.add('resize', { postId, tmpPath }, {
    attempts: 3,                              // retry a few times on failure
    backoff: { type: 'exponential', delay: 1000 },
    removeOnComplete: true,
  });
module.exports.imageQueue = imageQueue;

The worker is the separate process. It pulls jobs, resizes with sharp, saves the output, and updates the post โ€” the moment it flips status to ready, the next feed load shows the image:

// server/src/worker.js โ€” run with: node src/worker.js
require('dotenv').config();
const { Worker } = require('bullmq');
const sharp = require('sharp');
const path = require('path');
const connectDB = require('./config/db');
const Post = require('./models/Post');
const cache = require('./cache/postCache');

async function start() {
  await connectDB();
  const connection = { url: process.env.REDIS_URL };

  new Worker('image-processing', async (job) => {
    const { postId, tmpPath } = job.data;
    const outName = `${postId}.webp`;
    const outPath = path.join('uploads', outName);

    await sharp(tmpPath).resize(1080, 1080, { fit: 'inside' })
      .webp({ quality: 80 }).toFile(outPath);   // the slow part, off the request

    await Post.findByIdAndUpdate(postId, {
      image: { url: `/uploads/${outName}`, status: 'ready' },
    });
    await cache.invalidateFeed();               // fresh image โ‡’ bust the feed
    console.log(`Processed image for post ${postId}`);
  }, { connection });

  console.log('Image worker ready, waiting for jobs...');
}
start();

๐Ÿ“– The "return now, finish later" contract

This is the same shape as any async job system: the request's job is to accept work and acknowledge it, not to complete it. The client gets a 201 in milliseconds with a post that says "image processing." A second later the worker finishes and busts the cache, so the next feed fetch carries the ready image. The user never stared at a spinner, and the API stayed responsive even while a heavy resize ran โ€” because it ran somewhere else. Cloud storage (upload the processed .webp to S3/Cloudinary and store that URL instead) slots in right where the worker calls toFile.

Stage 6 โ€” Real-Time Notifications

The last connective tissue: when someone likes or comments on your post, you should know instantly. Polling is wasteful; this is what Socket.IO is for. The key integration idea is rooms โ€” each logged-in user joins a private room named after their id, so the server can push to one specific person rather than broadcasting to everyone.

Set up the Socket.IO server alongside Express, sharing the same HTTP server, and authenticate the socket with the same JWT the REST API uses โ€” auth is one system, not two:

// server/src/realtime/io.js
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');

let io;

function initIO(httpServer) {
  io = new Server(httpServer, {
    cors: { origin: process.env.CLIENT_URL, methods: ['GET', 'POST'] },
  });

  // Authenticate the socket handshake with the JWT.
  io.use((socket, next) => {
    try {
      const { token } = socket.handshake.auth;
      const payload = jwt.verify(token, process.env.JWT_SECRET);
      socket.userId = payload.sub;
      next();
    } catch {
      next(new Error('Unauthorized socket'));
    }
  });

  io.on('connection', (socket) => {
    socket.join(socket.userId);   // private room = the user's own id
  });
}

// Called from controllers to push a notification to one user.
function notifyUser(userId, notification) {
  if (io) io.to(String(userId)).emit('notification', notification);
}

module.exports = { initIO, notifyUser };

The like/comment controllers do their normal database work, then call notifyUser with the post's author. That single line is the whole real-time feature from the API's side:

// server/src/controllers/postController.js (like handler)
const { notifyUser } = require('../realtime/io');

exports.like = asyncHandler(async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });

  if (!post.likes.includes(req.userId)) post.likes.push(req.userId);
  await post.save();
  await cache.invalidateFeed();

  // Notify the author โ€” but not if they liked their own post.
  if (String(post.author) !== req.userId) {
    notifyUser(post.author, {
      type: 'like', postId: post.id, at: Date.now(),
    });
  }
  res.json({ likes: post.likes.length });
});

On the client, a hook connects the socket with the token and listens. When a notification arrives, the bell updates โ€” no refresh:

// client/src/hooks/useSocket.js
import { useEffect } from 'react';
import { io } from 'socket.io-client';

export function useSocket(onNotification) {
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (!token) return;

    const socket = io(import.meta.env.VITE_API_URL, { auth: { token } });
    socket.on('notification', onNotification);

    return () => socket.disconnect();   // clean up on unmount
  }, [onNotification]);
}

๐Ÿ’ก Rooms turn a broadcast into a whisper

Without rooms you'd emit every notification to every connected client and filter on the front end โ€” leaking other people's activity and wasting bandwidth. By having each socket join(userId) on connect, io.to(authorId).emit(...) reaches exactly one person's tabs. That the socket is authenticated with the same token as the REST API means the server always knows which user a connection belongs to โ€” auth done once, reused everywhere.

Stage 7 โ€” Run It End to End

Integration only counts when it all runs at once. You now have four things to start: Mongo, Redis (both from Docker in the prerequisites), the API server, and the worker โ€” plus the Vite client. A root script can launch the Node processes together; run the client in its own terminal.

// pulse/package.json (root) โ€” needs: npm i -D concurrently
{
  "scripts": {
    "api":    "nodemon server/src/server.js",
    "worker": "nodemon server/src/worker.js",
    "dev":    "concurrently -n api,worker \"npm:api\" \"npm:worker\""
  }
}
# Terminal 1 โ€” backend (API + worker together)
npm run dev

# Terminal 2 โ€” front end
cd client && npm run dev   # http://localhost:5173

Then walk the integration checklist โ€” each step proves that two systems are talking:

โœ… Prove the seams, not just the features

  • โ˜ Register + log in from the client โ†’ a token lands in localStorage and the feed loads (auth + CORS + API client)
  • โ˜ Reload the feed twice โ†’ the response flips cached: false then cached: true (Redis cache-aside)
  • โ˜ Create a post โ†’ the feed shows it immediately and the next response is cached: false again (invalidation)
  • โ˜ Attach an image โ†’ the post appears with status "processing", then the image shows up a moment later (Multer โ†’ queue โ†’ worker)
  • โ˜ Open a second browser as another user and like the first user's post โ†’ a notification appears live on the first user's bell (Socket.IO rooms)
  • โ˜ Stop the worker, upload an image, restart it โ†’ the queued job still runs and completes (durable queue)

That last one is worth savoring: because the job lives in Redis, killing the worker mid-flight doesn't lose the work โ€” restart it and the job runs. The same durability that makes queues useful in production makes this demo convincing.

โš ๏ธ Start services in the right order

The API and worker both connect to Mongo and Redis on boot; if those aren't up first, they'll crash-loop. Bring up the two Docker containers, confirm they're listening, then start Node. In production this is exactly what a depends_on in docker-compose or an orchestrator's health checks enforce โ€” the ordering problem doesn't go away, it just gets automated.

Stretch Goals

Cleared the required build with time to spare? Deepen the integration โ€” none of these are needed to pass the rubric, but each strengthens a real-world seam.

  • ๐Ÿ”’ Protect reads too โ€” put requireAuth on the feed and show only posts from people you follow (adds a following array to User)
  • ๐Ÿ—‚๏ธ Cloud storage โ€” have the worker upload the processed .webp to S3 or Cloudinary and store that URL, so images survive a server redeploy
  • ๐Ÿ”” Persist notifications โ€” write each notification to a Notification collection so the bell has history, not just live pushes
  • โฑ๏ธ Rate-limit the create route with a Redis counter so one user can't flood the feed
  • ๐Ÿ“ˆ Cache per-user feeds โ€” key the cache by user id once reads are personalized, and think through the new invalidation rules
  • ๐Ÿงช Integration tests โ€” spin up the API with supertest plus an in-memory Mongo and a test Redis, and assert a create busts the cache
  • ๐Ÿ“ฆ Dockerize the whole thing โ€” a docker-compose.yml with client, api, worker, mongo, and redis (a perfect on-ramp to next week)

Self-Check Rubric

Grade yourself before calling it done. Aim for "yes" across the first two columns; the stretch column is bonus. Because this is an integration project, the rubric weights connections as heavily as features.

AreaMeets expectations (required)Exceeds (stretch)
Monorepo & CORS Separate client/ and server/; API accepts the client origin; both run together Full docker-compose for all five services
Auth & API client JWT register/login; one axios client attaches the token via interceptor; middleware guards writes Auto-refresh tokens; protected, personalized reads
Feed CRUD Create, paginated list, read one, update, delete; likes & comments; honest status codes Following graph; only followees' posts in the feed
Caching Cache-aside on the feed with a TTL, invalidated on every write; visible cached flag flips correctly Per-user cache keys; Redis rate limiting
Upload & jobs Multer accepts an image; resize runs in a separate worker; request returns before it finishes; status flips to ready Cloud storage; retries/backoff observed on failure
Real-time Socket.IO authenticated with the JWT; like/comment pushes a live notification to the author's room only Persisted notification history; unread counts
Integration proof The Stage 7 checklist passes end to end with all services running Automated integration test asserting a write busts the cache

Summary

๐ŸŽ‰ What You Built

  • A two-tier monorepo โ€” a Vite/React client and an Express/MongoDB API โ€” talking across a controlled CORS boundary through a single token-aware API client
  • JWT authentication reused everywhere: guarding REST writes and authenticating the Socket.IO handshake, so the server always knows who's calling
  • A posts feed with full CRUD, made fast with Redis cache-aside and kept correct with invalidation on every write
  • Image upload that returns instantly by handing the heavy sharp resize to a BullMQ worker in its own process
  • Real-time notifications delivered to one user via Socket.IO rooms when their post is liked or commented on

The lasting lesson of this weekend isn't any single technology โ€” it's that a real application is defined by its joints. You saw a write bust a cache, an upload become a queued job, a database change fan out to a socket, and one token unlock both HTTP and WebSocket traffic. Individually these were Week 10 lessons; together they're an architecture. Knowing where the pieces connect, and what has to stay in sync across those seams, is the difference between a developer who can follow a tutorial and one who can design a system.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You just ran five moving parts by hand โ€” Mongo, Redis, an API, a worker, and a client โ€” and felt how fiddly it is to start them in the right order. Week 11 fixes exactly that. It opens with Container concepts: how Docker packages each of these services with its dependencies into a portable image, so "works on my machine" becomes "works everywhere," and docker-compose up replaces your five terminals with one command. Everything you assembled this weekend is about to become something you can ship as a single, reproducible unit.

๐ŸŽ‰ You finished Week 10!

You integrated an entire stack โ€” auth, CRUD, caching, background jobs, and real-time โ€” into one running app. Push it to GitHub and put it front and center in your portfolio: this is the project that shows you can build systems, not just features.