I built WorldCanvas: one shared online canvas where anyone can claim a 20×20 px square for $1 and put their own picture on it. It's a take on the 2005 Million Dollar Homepage, but for people's pictures instead of ads.
This post is about how it works under the hood. Honest status first: it's live, I'm building it solo (with an AI coding assistant), and the only two squares on the canvas are my own test uploads. No real buyers yet, so this is an architecture write-up, not a success story.
The model
- A 100×100 grid (10,000 squares), one square = 20×20 px. The grid size is a single env variable; the plan is to grow to 1000×1000.
- A claim is a rectangle of up to 9 squares; max 27 squares per email.
- Claim status:
pending→approved/rejected. Pending claims are drawn with a yellow tint so the spot can't be taken twice while I review it.
Stack
- Netlify: static site + Netlify Functions (Node.js) for the API: upload, canvas state, payment create/authorize, admin.
- Supabase: Postgres for claims and payments, Storage for source images, tiles and certificates.
- PayPal Orders API v2 for payments, Resend for email, jimp for image processing.
Why jimp instead of sharp
My first prototype ran on a regular server with sharp. On Netlify Functions I switched to jimp: pure JavaScript, no native binaries, no build headaches. The trade-off is speed and formats — the version I use doesn't read WebP, so only PNG and JPEG are accepted. I detect the format from magic bytes rather than trusting the file name or MIME type.
Tiles
The canvas isn't one giant image. It's served as tiles: a chunk of 20×20 squares = 400×400 px, so a 100×100 grid is 25 PNG tiles. When a claim changes (new, approved, rejected), only the tiles its rectangle intersects are re-rendered and saved to Storage.
Each picture is fitted into its rectangle with cropping (like fit: 'cover'). Pending claims get a simple per-pixel pass: slight desaturation plus a yellow blend.
The performance trap. At first I stored the original upload. A 4000+ px PNG was decoded again on every tile re-render, and saving a claim took tens of seconds — basically a guaranteed timeout in a serverless function. The fix was boring: downscale a stored copy to 800 px on the long side at upload time (a square is only 20 px on the canvas, and the certificate uses ~700 px).
Catching duplicates
Before saving, I compute a perceptual average hash: resize to 8×8, convert to greyscale, compare each pixel to the mean brightness → a 64-bit string. Images within a Hamming distance of 6 are treated as similar. It's not content moderation, just a cheap way to catch the same image being spammed.
Automated content moderation is still an honest stub with a hook for an external service. A human (me) looks at every image.
Payments: authorize → capture / void
This is the core decision. "Pay and get it" doesn't work here: I don't want to take money for a picture I might reject.
So the order uses the AUTHORIZE intent:
- The user picks squares, uploads a file and approves the payment in the PayPal widget.
- The server calls
POST /v2/checkout/orders/{id}/authorize. The money is held, not charged. I store theauthorization_id. - In the admin panel I approve or reject:
- approve →
POST /v2/payments/authorizations/{id}/capture, and the claim becomesapprovedonly if the capture status isCOMPLETED; - reject →
POST /v2/payments/authorizations/{id}/void, the hold is released, nothing is charged.
- approve →
- If something fails after authorization (squares got taken, limit exceeded, broken file), the authorization is voided too.
The catch is timing. A PayPal authorization has a short honor period (about three days). You can reauthorize, but not forever — up to 29 days in total. For now I go through the queue by hand every day; automatic reauthorization isn't built yet.
Certificates
After approval, a PNG certificate is generated: the participant's picture, the date and a number. For this I wrote a tiny dependency-free PNG encoder/decoder (just Node's zlib). The certificate is stored under a path with a random suffix so links can't be guessed by number, and it's emailed to the buyer via Resend.
Emails
- "request received" right after upload;
- "approved" + certificate;
- "rejected" + reason;
- a notification to me with a link to the admin panel.
All sends are "soft": they have a timeout, and if Resend is down, the upload still goes through.
What I'd do differently
- Store a downscaled copy from day one, not the original.
- Start with authorize/void. My first version captured immediately and refunded on rejection, which is worse for both the buyer and me.
- Don't start with "a million squares on day one". 100×100 covered 90% of the problems and let me launch.
Next
A pan/zoom viewer with lazy tile loading for 1000×1000, automatic reauthorization, and automated moderation through an external service.
Questions about the architecture, or ideas on moderation at this scale, are very welcome in the comments.
Canvas: https://worldcanvas.world
Top comments (0)