A small Express + TypeScript service that accepts an image upload and generates five resized WebP variants of it in one request. The resizing work is wired up using the Observer design pattern, which makes this a good teaching project for both image processing and classic OO patterns.
- What this project does
- Tech stack
- Project structure
- Setup
- Running the server
- Using the API
- How it works — the full walkthrough
- The Observer pattern, explained
- Request lifecycle diagram
- Extending the project
- Known gotchas
- Troubleshooting
You POST a single image file to the server. The server:
- Saves the original file to disk (
src/storage/). - Creates five resized copies at widths 50, 100, 200, 500 and 600 px.
- Converts every copy to WebP at quality 80.
- Returns JSON metadata describing each generated file.
This is the same thing a CDN or a CMS does when it generates thumbnails — one upload, many derivative sizes.
| Package | Role |
|---|---|
express |
HTTP server and routing |
multer |
Parses multipart/form-data and writes the upload to disk |
sharp |
High-performance image resizing / format conversion (libvips) |
typescript |
Type safety |
ts-node-dev |
Runs TypeScript directly and restarts on file changes |
@types/express, @types/multer |
Type definitions |
Why sharp? It's backed by libvips (native C), so it's several times faster
and far lighter on memory than ImageMagick-based alternatives.
ImageProcessing/
├── index.ts # Entry point — creates the Express app, listens on :3000
├── package.json
├── tsconfig.json
└── src/
├── index.ts # Barrel file — re-exports the router
├── routes/
│ └── index.ts # Route definition + multer middleware
├── service/
│ └── index.ts # Observer pattern + sharp resize logic
├── utills/
│ └── images.ts # Multer disk-storage configuration
└── storage/ # Uploaded originals + generated variants land here
The layering is deliberate and worth pointing out in a tutorial:
- routes decides what URL and what middleware.
- service decides what work happens.
- utills holds infrastructure config (where files go).
Each layer can be tested and swapped independently.
- Node.js 18+ (Node 18 or 20 recommended;
sharpships prebuilt binaries for these) - npm
git clone <your-repo-url>
cd ImageProcessing
npm installnpm install downloads a prebuilt sharp binary for your platform. No compiler
toolchain is needed on macOS, Windows, or common Linux distros.
The app writes into src/storage/. If it doesn't exist, multer will throw
ENOENT on the first upload:
mkdir -p src/storageTip: Git doesn't track empty folders. Add a
src/storage/.gitkeepfile so the directory exists after a fresh clone, and addsrc/storage/*(except.gitkeep) to.gitignoreso generated images don't get committed.
tsconfig.json uses a minimal, strict setup:
esModuleInterop is the important one here — without it, CommonJS packages like
express and multer would need import * as express from "express".
npm startWhich runs:
ts-node-dev index.ts --poll
ts-node-devcompiles TypeScript in memory and restarts the process when a file changes — no build step during development.--polluses polling instead of native filesystem events. This is needed inside Docker, WSL, and some network-mounted volumes wherefs.watchdoesn't fire.
You should see:
Server listening on port 3000
| Content-Type | multipart/form-data |
| Field name | image (must match exactly) |
| Body | one image file |
curl -X POST http://localhost:3000/ \
-F "image=@/path/to/photo.jpeg"{
"success": true,
"data": [
{ "format": "webp", "width": 50, "height": 33, "channels": 3, "size": 1204, "premultiplied": false },
{ "format": "webp", "width": 100, "height": 67, "channels": 3, "size": 2810, "premultiplied": false },
{ "format": "webp", "width": 200, "height": 133, "channels": 3, "size": 7942, "premultiplied": false },
{ "format": "webp", "width": 500, "height": 333, "channels": 3, "size": 32118, "premultiplied": false },
{ "format": "webp", "width": 600, "height": 400, "channels": 3, "size": 44903, "premultiplied": false }
]
}The array is sharp.OutputInfo — sharp's report of what it actually wrote.
Uploading pexels-photo-18161318.jpeg gives you:
src/storage/
├── pexels-photo-18161318.jpeg # original, untouched
├── 50_pexels-photo-18161318.jpeg # 50px wide, WebP data
├── 100_pexels-photo-18161318.jpeg
├── 200_pexels-photo-18161318.jpeg
├── 500_pexels-photo-18161318.jpeg
└── 600_pexels-photo-18161318.jpeg
import express from "express";
import { appRouter } from "./src";
const app = express();
app.use(appRouter);
app.listen(3000, () => {
console.log("Server listening on port 3000");
});Three lines of substance:
express()creates the application.app.use(appRouter)mounts the router at the root path, so a route declared asPOST /inside the router is reachable atPOST /. If you changed this toapp.use("/images", appRouter), the endpoint would becomePOST /images.app.listen(3000, ...)binds the HTTP server.
Note there is no express.json() middleware, and that's correct — this
endpoint receives multipart/form-data, not JSON. express.json() would ignore
it anyway; multer is the parser for multipart bodies.
import appRouter from "./routes";
export { appRouter };This is a barrel: a file whose only job is to re-export things so consumers
can import from a single, stable path (./src) instead of reaching into
./src/routes/index. It keeps the entry point decoupled from internal folder
layout.
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.resolve(__dirname, "../", "storage"));
},
filename: (req, file, cb) => {
cb(null, file.originalname);
},
});
export const upload = multer({ storage });Multer offers two storage engines:
memoryStorage— the file lands inreq.file.buffer(RAM). Fast, but a large upload sits entirely in memory.diskStorage— the file is streamed to disk and you getreq.file.path. Used here, which is whysharp()can be handed a file path later.
Both callbacks follow Node's error-first convention: cb(error, value). Passing
null as the first argument means "no error, here's the value".
destinationresolves tosrc/storage.__dirnameissrc/utillsat runtime, so../storagelands insrc/storage.filenamekeeps the client's original filename. Simple and readable for a tutorial — see Known gotchas for why production code should not do this.
upload is now a configured multer instance, ready to be used as middleware.
const route = Router();
route.post("/", upload.single("image"), imageService);
export default route;upload.single("image") is middleware that:
- Parses the
multipart/form-databody. - Finds the part with field name
image. - Writes it to
src/storage/<originalname>. - Attaches a descriptor to
req.file, then callsnext().
By the time imageService runs, the file is already on disk. req.file looks
like:
{
fieldname: "image",
originalname: "photo.jpeg",
mimetype: "image/jpeg",
destination: "/…/src/storage",
filename: "photo.jpeg",
path: "/…/src/storage/photo.jpeg",
size: 482913
}Related multer variants worth knowing: upload.array("images", 5) for multiple
files under one field, upload.fields([...]) for several named fields, and
upload.none() for text-only multipart forms.
This file holds the interesting part. It has three pieces:
a) The contracts
interface Subject {
attach(observer: Observer): void;
}
interface Observer {
resize(): Promise<sharp.OutputInfo>;
}A Subject can collect observers. An Observer knows how to do one unit of
work and report the result.
b) The subject — the registry and trigger
class ConcreteSubject implements Subject {
private observers: Observer[] = [];
attach(observer: Observer): void {
this.observers.push(observer);
}
async fire() {
const output: sharp.OutputInfo[] = [];
for (const observer of this.observers) {
const result = await observer.resize();
output.push(result);
}
return output;
}
}attach registers. fire walks every registered observer, awaits its work, and
collects the results. Crucially, the subject has no idea what resizing is —
it only knows the Observer interface. That's the whole point of the pattern.
The loop is sequential (await inside for…of), so the five resizes happen
one after another. That's a deliberate, readable choice; see
Extending for the parallel version.
c) The observer — the actual image work
class ImageObserver implements Observer {
constructor(private image: Express.Multer.File, private size: number) {}
async resize(): Promise<sharp.OutputInfo> {
return await sharp(this.image?.path)
.resize(this.size, null, { fit: "cover" })
.webp({ quality: 80 })
.toFile(
path.resolve(this.image.destination, `${this.size}_${this.image.filename}`)
);
}
}TypeScript's parameter properties (private image, private size in the
constructor signature) declare and assign the fields in one step — no explicit
this.image = image needed.
The sharp chain reads left to right as a pipeline:
| Call | Effect |
|---|---|
sharp(path) |
Opens the source image and returns a chainable pipeline |
.resize(width, null, { fit: "cover" }) |
Fixes the width; null height means preserve aspect ratio |
.webp({ quality: 80 }) |
Encodes as WebP; 80 is the usual quality/size sweet spot |
.toFile(dest) |
Executes the pipeline and writes the file, resolving to OutputInfo |
Nothing actually happens until .toFile() — sharp builds the operation graph
lazily and runs it once at the end, in a single pass through libvips.
d) The handler that ties it together
export const imageService = async (req: Request, res: Response) => {
const image = req.file as Express.Multer.File;
const subject = new ConcreteSubject();
subject.attach(new ImageObserver(image, 50));
subject.attach(new ImageObserver(image, 100));
subject.attach(new ImageObserver(image, 200));
subject.attach(new ImageObserver(image, 500));
subject.attach(new ImageObserver(image, 600));
const data = await subject.fire();
return res.status(200).json({ success: true, data });
};Read it as a sentence: build a subject, register one observer per target size, fire once, return what came back.
Definition: a subject maintains a list of observers and notifies them all when an event occurs, without knowing anything about what they do.
Mapping onto this project:
| Pattern role | This codebase |
|---|---|
| Subject | ConcreteSubject — holds the observer list, fire() is the notification |
| Observer interface | Observer — the resize() contract |
| Concrete observer | ImageObserver — one per target size |
| Event | The upload finishing, i.e. the call to fire() |
Why it's a good fit here: adding a 1200px variant is a single line —
subject.attach(new ImageObserver(image, 1200));— with zero changes to ConcreteSubject. That's the Open/Closed Principle
in practice: open for extension, closed for modification.
Where it stretches the classic definition: in textbook Observer, notify()
is fire-and-forget and observers don't return values. Here fire() awaits each
observer and collects results, which makes it closer to an async pipeline or a
command dispatcher. That's a fine adaptation for a real API — but worth naming
explicitly so the concept lands accurately.
Observers also don't have to be resizers. You could attach a
WatermarkObserver, a ThumbnailUploadObserver that pushes to S3, or a
MetadataObserver that writes a database row — the subject wouldn't change.
Client
│ POST / (multipart/form-data, field: image)
▼
Express app (index.ts)
│
▼
appRouter (src/routes/index.ts)
│
▼
multer upload.single("image") ← src/utills/images.ts
│ writes original → src/storage/<name>
│ sets req.file
▼
imageService (src/service/index.ts)
│
├─ new ConcreteSubject()
├─ attach ImageObserver(50)
├─ attach ImageObserver(100)
├─ attach ImageObserver(200)
├─ attach ImageObserver(500)
├─ attach ImageObserver(600)
│
▼
subject.fire()
│ for each observer:
│ sharp(src) → resize(w) → webp(80) → toFile(<w>_<name>)
│
▼
res.status(200).json({ success: true, data })
The sequential loop is easy to read but leaves throughput on the table. Since each observer writes to a different file, they're independent:
async fire() {
return Promise.all(this.observers.map((o) => o.resize()));
}Prefer Promise.allSettled if you'd rather report partial success than fail the
whole request when one variant errors.
app.use("/images", express.static(path.resolve(__dirname, "src/storage")));Then 50_photo.jpeg is available at http://localhost:3000/images/50_photo.jpeg.
export const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) {
return cb(new Error("Only image uploads are allowed"));
}
cb(null, true);
},
});const SIZES = [50, 100, 200, 500, 600];
SIZES.forEach((size) => subject.attach(new ImageObserver(image, size)));class WatermarkObserver implements Observer {
constructor(private image: Express.Multer.File) {}
async resize(): Promise<sharp.OutputInfo> {
return sharp(this.image.path)
.composite([{ input: "watermark.png", gravity: "southeast" }])
.toFile(path.resolve(this.image.destination, `wm_${this.image.filename}`));
}
}Attach it alongside the resizers. ConcreteSubject needs no changes — which is
exactly the payoff the pattern promises.
These are real characteristics of the current code. Leaving them in a tutorial is fine, but call them out so learners don't ship them.
1. Output files keep the source extension. 600_photo.jpeg contains WebP
data, not JPEG. Browsers sniff content type and will render it, but the filename
lies. Fix by swapping the extension:
const base = path.parse(this.image.filename).name;
.toFile(path.resolve(this.image.destination, `${this.size}_${base}.webp`))2. { fit: "cover" } does nothing here. fit only applies when both
width and height are given. With height null, sharp always preserves aspect
ratio. Either drop the option or pass a real height.
3. Filename collisions. filename: file.originalname means two users
uploading photo.jpg overwrite each other. Production fix:
filename: (req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${unique}${path.extname(file.originalname)}`);
}4. No error handling. If req.file is undefined (no file sent) or sharp
throws on a corrupt file, the async handler rejects and — in Express 4 — the
request hangs until it times out. Wrap the body in try/catch and add an error
middleware:
export const imageService = async (req: Request, res: Response) => {
try {
const image = req.file;
if (!image) return res.status(400).json({ success: false, message: "No image uploaded" });
// …
} catch (err) {
return res.status(500).json({ success: false, message: (err as Error).message });
}
};5. Upscaling. Uploading a 300px-wide image still produces 500px and 600px
variants — sharp enlarges by default. Pass withoutEnlargement: true to the
resize options to skip that.
6. Hardcoded port. 3000 is baked in. Use process.env.PORT ?? 3000.
7. Folder name typo. src/utills should be src/utils. Harmless, but worth
fixing before anyone else reads the repo.
| Symptom | Cause | Fix |
|---|---|---|
ENOENT: no such file or directory … /storage |
Storage folder missing | mkdir -p src/storage |
MulterError: Unexpected field |
Form field isn't named image |
Use -F "image=@file.jpg" |
| Request hangs, no response | No file sent → req.file undefined → handler throws |
Add the if (!image) guard from gotcha #4 |
sharp install fails |
No prebuilt binary for your platform/Node version | Use Node 18/20, then npm rebuild sharp |
Cannot find module './src' |
Running from the wrong directory | Run npm start from the project root |
| Changes don't trigger a restart | Filesystem events not firing (Docker/WSL) | --poll is already set; raise the interval with --poll-interval |
EADDRINUSE: port 3000 |
Another process holds the port | lsof -ti:3000 | xargs kill or change the port |
ISC
{ "compilerOptions": { "target": "es2016", "module": "commonjs", "esModuleInterop": true, // lets you write `import express from "express"` "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true } }