DEV Community

Cover image for Compress images from Node.js with the Blinkimg API and fetch
Sahadat Husain
Sahadat Husain

Posted on

Compress images from Node.js with the Blinkimg API and fetch

You can compress an image from Node.js with four HTTP calls and the built-in fetch: ask for an upload slot, PUT the bytes, create a job, then download the result. I sent a 9,804,300-byte camera JPEG with targetBytes: 100000 and got back a 96,112-byte AVIF at the full 4964 × 2782. The script below has no dependencies.

What does the Blinkimg API flow look like?

The API is plain HTTPS and JSON at https://api.blinkimg.com/v1. A compress takes three endpoints:

Call Sends Gets back
POST /v1/uploads filename, contentLength uploadId, url, urlExpiresAt, maxBytes
POST /v1/jobs uploadId, options the job: id, status, result, error
GET /v1/jobs/{id}/result nothing url, filename, expiresAt

Between the first two you PUT the file to the signed url. The bytes go straight to object storage, so there is no multipart body to build. The developer API page lists the same contract with cURL and Python versions.

The four requests in order: POST uploads, PUT the bytes, POST jobs and poll, GET result

How do you call it from Node with fetch?

Node 18 and later ship fetch globally. This is the whole client:

// compress.mjs: node compress.mjs photo.jpg photo.avif
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';

const API = 'https://api.blinkimg.com/v1';
const KEY = process.env.BLINKIMG_KEY;
const auth = KEY ? { Authorization: `Bearer ${KEY}` } : {};
const json = { ...auth, 'Content-Type': 'application/json' };

async function call(path, init = {}) {
  const res = await fetch(`${API}${path}`, init);
  const body = await res.json();
  if (!res.ok) {
    const e = body.error ?? {};
    throw new Error(`${res.status} ${e.code}: ${e.message} (request ${e.requestId})`);
  }
  return body;
}

export async function compress(input, output, options) {
  const file = await readFile(input);
  const t0 = performance.now();

  // 1. Ask for an upload slot, then PUT the bytes straight to storage.
  const upload = await call('/uploads', {
    method: 'POST',
    headers: json,
    body: JSON.stringify({ filename: basename(input), contentLength: file.length }),
  });
  const put = await fetch(upload.url, { method: 'PUT', body: file });
  if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);

  // 2. Create the job.
  let job = await call('/jobs', {
    method: 'POST',
    headers: json,
    body: JSON.stringify({ uploadId: upload.uploadId, options }),
  });

  // 3. Poll until it leaves the queue.
  while (job.status === 'queued' || job.status === 'processing') {
    await new Promise((r) => setTimeout(r, 500));
    job = await call(`/jobs/${job.id}`, { headers: auth });
  }
  if (job.status !== 'succeeded') {
    throw new Error(`job ${job.status}: ${job.error?.code} ${job.error?.message}`);
  }

  // 4. Fetch the signed result link, then the file.
  const result = await call(`/jobs/${job.id}/result`, { headers: auth });
  const bytes = Buffer.from(await (await fetch(result.url)).arrayBuffer());
  await writeFile(output, bytes);
  return { ...job.result, filename: result.filename, ms: Math.round(performance.now() - t0) };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const [input = 'photo.jpg', output = 'photo.avif'] = process.argv.slice(2);
  console.log(await compress(input, output, { format: 'avif', targetBytes: 100_000 }));
}
Enter fullscreen mode Exit fullscreen mode

The key goes in the Authorization: Bearer header. Create one from your account and export it as BLINKIMG_KEY. Do not send the key to the signed upload or result URL: those carry their own signature.

What does the job return?

The job's result field reports what the engine did. My run printed:

{
  inputFormat: 'jpeg',
  outputFormat: 'avif',
  inputBytes: 9804300,
  outputBytes: 96112,
  savedBytes: 9708188,
  savedPercent: 99,
  width: 4964,
  height: 2782,
  returnedOriginal: false,
  filename: 'photo.avif',
  ms: 50444
}
Enter fullscreen mode Exit fullscreen mode

The AVIF landed at 96,112 bytes, under the 100,000-byte cap, at full resolution. The whole round trip took 50.4 seconds on one run. That time includes uploading 9.8 MB from my connection and the engine searching for the quality that fits the cap on a 13.8-megapixel photo.

How do you resize in the same call?

Add resize to the options. The engine fits the image inside the bounds and never enlarges it:

await compress('photo.jpg', 'photo-1920.avif', { format: 'avif', resize: { width: 1920 } });
Enter fullscreen mode Exit fullscreen mode
{
  inputFormat: 'jpeg',
  outputFormat: 'avif',
  inputBytes: 9804300,
  outputBytes: 69176,
  savedBytes: 9735124,
  ...
  width: 1920,
  height: 1076,
  ms: 9287
}
Enter fullscreen mode Exit fullscreen mode

That run, at Auto quality, took 9.3 seconds end to end and wrote 69,176 bytes. A smaller output is a much shorter job; the upload is the same.

What happens when an option is wrong?

Every non-2xx answer uses one envelope: { error: { code, message, requestId } }. The call() helper turns it into one line. Two mistakes I made on purpose:

400 BAD_REQUEST: Too small: expected number to be >=10000 (request 564c0fca-...)
400 BAD_REQUEST: quality and targetBytes are mutually exclusive: a byte budget picks its own quality (request bacbe39a-...)
Enter fullscreen mode Exit fullscreen mode

targetBytes has a floor of 10,000 bytes. quality and targetBytes cannot be sent together, because a byte budget picks its own quality.

Note: Both errors arrive at POST /v1/jobs, after the upload. If you build options from user input, validate them before you send the file, or you pay for the upload twice. Keep the requestId from any error; it is what support needs.

What does a call cost?

GET /v1/plans answers without a key and lists the costs. On 2026-10-17 it returned 500 free credits a month, 1 credit for a compress and 2 credits for a compress plus one format change. The JPEG to AVIF job above is the second kind. Keyed requests are shaped at 120 writes a minute.

If you would rather call the same engine from an AI editor than from a script, the MCP server setup uses the same credits.

How I measured

  • File: apps/web/public/images/index/image-compressor-sample-camera-photo.jpeg, 4964 × 2782, 9,804,300 bytes, copied as photo.jpg.
  • Node 24.22.0 with its built-in fetch, on an Apple M1 Pro, 2026-10-17, against the live https://api.blinkimg.com/v1.
  • I ran the script without BLINKIMG_KEY set, through the anonymous, rate-limited path the website itself uses. The endpoints, bodies and responses are the same with a key.
  • Times are one run each, wall clock, including my upload. They are not a benchmark.
  • Field names come from the API's request and response schemas and the live responses above.

What I did not test

Batches, ZIP and PDF output, and minBytes. I did not test the 429 path or retries, and did not time the upload and the encode separately.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)