Serverless Image Manipulation

.

.

.


What is this doing?

At a high level we accept an uploaded image into an in-memory file blob in the browser, and then post that file as part of a multipart form along with some extra info to a URL with the shape {HOSTNAME}/api/image/{ACTION}.

This URL corresponds to a Go serverless function deployed alongside this site as part of the same monorepo on Vercel. That function processes the image and returns the result, which we use to replace the in-memory file blob client-side.


How is it doing it?

When an image is uploaded we collect the image into a file blob, then use URL.createObjectURL() to display a preview of the working image:

function setPreviewSrc(img: Blob) { // imgEl refers to a bound <img /> element imgEl.src = URL.createObjectURL(img); imgEl.onload = () => { URL.revokeObjectURL(imgEl.src); // free memory }; }

After the image is uploaded we show a preview, as well as a form that contains several inputs as described here. A select sets the action to be applied to the image, and also toggles additional inputs depending on the option selected. We use a hidden input to send the content type (collected on upload) along with the form data:

{#if showPreview} <!-- PREVIEW --> <form bind:this={form} on:submit|preventDefault={handleSubmit} class="..."> <input hidden name="content_type" bind:value={contentType} /> <div> <label for="formAction" class="..."> Select an action </label> <select id="formAction" name="action" bind:value={action} class="..."> {#each Object.entries(actions) as [value, name]} <option {value}>{name}</option> {/each} </select> </div> {#if action == "blur"} <!-- BLUR SLIDER --> {:else if action == "pixelate"} <!-- PIXELATE SLIDER --> {/if} ... </form> {/if}

We then use the Javascript Fetch API to POST to the relevant transform API endpoint — our serverless function:

// build form const fData = new FormData(form); fData.set("image", image); const gRes = await fetch(`http://localhost:3000/api/image/${action}`, { method: "POST", body: fData, });

In the Go function we extract the image from the multipart form and then decode it based on the content type:

// this keeps the whole form in memory, so be careful of space err := r.ParseMultipartForm(32 << 20) // max 32MB if err != nil { w.WriteHeader(http.StatusBadRequest) return } file, _, err := r.FormFile("image") if err != nil { w.WriteHeader(http.StatusBadRequest) return } defer file.Close() contentType := r.PostFormValue("content_type") // util.Decode() is an internal function that contains // a simple switch statement used to decode the image // correctly based on contentType img, err := util.Decode(file, contentType) if err != nil { w.WriteHeader(http.StatusUnsupportedMediaType) return }

Once we decode the image we perform some processing on it depending on the function being called. We then re-encode the image to a buffer and write it to the response:

processedImg := someProcess(img) outputBuf := new(bytes.Buffer) // like util.Decode(), util.Encode() is an internal function // that contains a simple switch statement used to encode the image err = util.Encode(outputBuf, processedImg, contentType) if err != nil { panic(err) } w.Header().Set("Content-Type", "application/octet-stream") w.Write(outputBuf.Bytes())

Finally, we retrieve the new binary from the response on the client side and set the preview to use the new URL:

if (gRes.status < 400) { // set image to blob from response image = await gRes.blob(); } setPreviewSrc(image);

New for 2025!

Rather than applying a single transform at a time and making multiple round trips to the server, we now support chaining multiple transforms in a single request. This lets you build a pipeline of operations that are applied in sequence.

On the client side, we build an array of transform objects and serialize it as JSON in the form data:

// Build the transforms array const transforms = [ { type: "blur", blur_strength: 5 }, { type: "grayscale" }, { type: "pixelate", pixel_size: 10 } ]; const fData = new FormData(form); fData.set("image", image); fData.set("transforms", JSON.stringify(transforms)); const gRes = await fetch(`${API_URL}/api/image/chain`, { method: "POST", body: fData, });

On the server, we parse the transforms array and apply each operation in order. A registry maps transform types to their constructors, making it easy to add new transforms without modifying the handler:

// Parse the transforms JSON from the form transformsJSON := r.PostFormValue("transforms") var specs []TransformSpec if err := json.Unmarshal([]byte(transformsJSON), &specs); err != nil { w.WriteHeader(http.StatusBadRequest) return } // Apply each transform in order using the registry currentImg := img for _, spec := range specs { constructor, ok := transform.Registry[spec.Type] if !ok { w.WriteHeader(http.StatusBadRequest) return } t, err := constructor(spec.Params) if err != nil { w.WriteHeader(http.StatusBadRequest) return } currentImg, err = t.Apply(currentImg) if err != nil { panic(err) } }

Et voilà!

Source on GitHub
© 2025 William Wernert