DrawSketch hero
03Full-Stack Web Application

DrawSketch

Browser-based vector drawing app with cloud sync

A canvas-based drawing app with a file-system organizer, cloud sync, and public sharing — works offline without an account.

TypeScriptReactNode.jsMongoDBRedis
01 // Overview

About the Project

What it is

DrawSketch is a browser-based drawing application built on HTML Canvas and Rough.js. It supports 13 drawing tools, a nested file/folder system, undo/redo history, and exports to PNG, SVG, JSON, and ZIP.

Who it's for

Anyone who wants a local-first sketching tool that optionally syncs to the cloud. The app is fully usable without an account, storing all data in localStorage.

Why it exists

The project was built to explore implementing a canvas drawing engine from scratch — no third-party whiteboard library — paired with a production-grade backend that handles authentication, file persistence, and public sharing.

02 // My Role

What I Built

Canvas Engine

Built the entire rendering layer using raw HTML Canvas and Rough.js, including element hit-testing, viewport transforms, alignment guides, and a rAF-based draw loop that avoids React re-renders during drag.

Backend & API

Designed the REST API using Express 5 and Prisma with MongoDB. Implemented a full auth system (JWT, refresh tokens, OTP email verification, Redis token blacklist) and file/folder CRUD with ownership checks.

Cloud Sync

Implemented a debounced canvas sync hook that writes to localStorage immediately and pushes to the API after 2 seconds of inactivity. The file-system hook keeps a local-to-remote ID map for offline/online reconciliation.

Infrastructure

Containerized the backend with a multi-stage Docker build (Chainguard builder, Distroless production runtime). Configured Docker Compose with health-checked MongoDB and Redis services.

03 // Key Capabilities

What It Can Do

Drawing tools: pencil (freehand), rectangle, ellipse, diamond, triangle, line, arrow, text, image, frame, eraser, select, hand

Per-file undo/redo history stack (up to 80 entries) with history seeding on file open

File tree with nested folders, drag-and-drop moves, rename, duplicate, and search

Export to PNG, SVG, JSON (.excalidraw), and ZIP archive of all files

04 // Under the Hood

Engineering Deep Dive

Click any card to read the full technical detail.

05 // Engineering Decisions

Challenges & Trade-offs

The real decisions behind the architecture.

⚠ Challenge

How to sync canvas changes to the API without blocking the drawing experience

→ Decision

Used a 2-second debounced effect (useSync hook) that fires only when the authenticated user modifies an open file with a known remote ID, writing to localStorage synchronously and deferring the API call

⇄ Trade-off

A crash within 2 seconds of the last edit could lose the most recent stroke, but localStorage always has the current state so data loss is bounded to the API copy only

⚠ Challenge

Keeping drag animations smooth without bypassing React's state model entirely

→ Decision

Maintained parallel refs (elementsRef, vpRef, liveOffsetRef) that mirror React state and are read directly by the rAF draw loop, committing back to React state only on mouseUp

⇄ Trade-off

Two sources of truth (refs and state) require careful synchronization via effects, but the result is jank-free panning and moving of elements

⚠ Challenge

File IDs differ between localStorage (nanoid) and MongoDB (ObjectId)

→ Decision

When authenticated the useFileSystem hook uses the backend ID directly as the local ID and stores a ds_remote_ids map. When offline, nanoid IDs are used and the map is empty

⇄ Trade-off

Simplifies online lookups but requires clearing and re-bootstrapping the ID map on login/logout

⚠ Challenge

Text editing on canvas requires a real textarea for IME and accessibility support, but committing text must not double-fire when blur is triggered by the same mouseDown that calls commitText

→ Solution

Introduced a committingRef boolean flag that is set before calling commitText on mouseDown. The textarea's onBlur handler skips the second commit if the flag is already true

✓ Result

Text is committed exactly once whether the user clicks away or presses Enter/Escape

⚠ Challenge

Arrow elements bound to shapes must follow their target shapes when those shapes are moved

→ Solution

During live drag rendering, the draw loop inspects boundStartId and boundEndId on arrow elements and applies the same dx/dy offset to the bound endpoints. On mouseUp, the final positions are written to React state

✓ Result

Arrows stay connected to their bound shapes throughout a move operation without modifying the element array during drag

⚠ Challenge

Importing Excalidraw-format files into a different schema

→ Solution

The importJSON function parses the type: excalidraw discriminator and maps Excalidraw fields to the internal schema, including converting angle (radians) to rotation (degrees), freedraw to pencil, and fontFamily integers to CSS strings

✓ Result

Users can import standard .excalidraw files and have them open correctly in DrawSketch

06 // Architecture

System Design

Frontend: React 19 SPA (Vite) with a single large App.tsx orchestrating state, two canvas layers (main + overlay), and a component tree for Sidebar, TopBar, ToolBar, and PropsPanel
Canvas rendering: Rough.js + raw HTML Canvas API; a separate overlay canvas handles selection rectangles via rAF without triggering React re-renders
Backend: Express 5 + TypeScript, organized into Auth and FileSystem feature modules (route → controller → service), with Prisma as the MongoDB ORM
Session layer: JWTs (15-minute access, 7-day refresh); Redis stores OTP codes (10-minute TTL) and token blacklist entries keyed by token with expiry-accurate TTL
Data persistence: MongoDB via Prisma (User, Folder, File models); canvas state serialized as JSON stored in the File.canvasData field; localStorage used as offline cache
07 // Performance

What Makes It Fast

Selection

Selection rectangle is drawn on a separate transparent overlay canvas via rAF, so rubber-band selection never triggers a full main-canvas redraw

Response-time

Response-time middleware categorizes each request as VERY FAST / FAST / NORMAL / SLOW / VERY_SLOW / CRITICAL and emits a SLOW_RESPONSE warning log for requests over 1 second

Winston

Winston uses daily-rotating file transports: info logs rotate daily and are kept 14 days; error logs are gzip-archived and kept 30 days

The

The Docker production image uses Distroless (nonroot) as the runtime, reducing attack surface and image size compared to a full Node image

08 // Security

Security Model

Helmet sets secure HTTP headers; HPP prevents HTTP parameter pollution

Helmet sets secure HTTP headers; HPP prevents HTTP parameter pollution

Two separate rate limiters

Two separate rate limiters: a general limiter (100 req/15 min) on all /api routes, and a stricter auth limiter (10 req/15 min) on login, register, forgot-password, resend-otp, and verify-email

Logout blacklists the access token in Redis with a TTL equal to the token's remaining lifetime; the auth middleware checks the blacklist on every protected request

Logout blacklists the access token in Redis with a TTL equal to the token's remaining lifetime; the auth middleware checks the blacklist on every protected request

Forgot-password always returns the same generic message regardless of whether the email exists, preventing user enumeration

Forgot-password always returns the same generic message regardless of whether the email exists, preventing user enumeration

09 // Deployment

How It Ships

Backend: multi-stage Dockerfile — Chainguard node:latest-dev for the build stage (compiles TypeScript, runs prisma generate, prunes dev dependencies); gcr.io/distroless/nodejs22-debian12:nonroot as the production stage
Docker Compose: three services (app, mongo:7, redis:7-alpine) on an isolated bridge network; MongoDB and Redis include healthcheck probes with retry logic before the app container starts
The server handles SIGTERM and SIGINT with graceful shutdown: closes the HTTP server, then disconnects Prisma and Redis, with a 10-second force-exit timeout
Frontend: Vite build deployed to Vercel (vercel.json present in the repository)
10 // Gallery

Screenshots

Architecture Diagrams
11 // Resources

Links & Assets

01 // HERO