A CMYK JPEG needs an ICC conversion to sRGB before it goes on a web page, and sharp already does one by default: any .jpeg(), .webp() or .avif() output comes out as 3-channel sRGB. What you must not do is convert the pixels yourself with the R = 255 × (1 − C) × (1 − K) formula. On my test file that formula landed 27.52 levels off per channel on average, and 130 levels at worst.

How do you detect a CMYK upload?

Read the metadata. sharp reports the colour space and the channel count before it decodes a pixel:

const sharp = require('sharp'); const m = await sharp(buffer).metadata(); console.log(m.space, m.channels, m.hasProfile); 

For the CMYK file below, that prints cmyk 4 true. Log it. CMYK uploads usually come from a print designer or a stock library, and the log tells you how often they arrive.

Making a CMYK test file

You need a CMYK JPEG to test with. sharp can write one: toColourspace('cmyk') converts through the CMYK profile that ships with libvips, and keepIccProfile() embeds that profile in the output.

const src = 'packages/engine/tests/fixtures/photo.jpg'; // 800 × 600, sRGB const cmyk = await sharp(src).toColourspace('cmyk').jpeg({ quality: 90 }).keepIccProfile().toBuffer(); const untagged = await sharp(src).toColourspace('cmyk').jpeg({ quality: 90 }).toBuffer(); 
cmyk-tagged.jpg jpeg cmyk channels 4 icc 961644 B 1447947 B cmyk-untagged.jpg jpeg cmyk channels 4 icc none 486033 B 

Look at the profile: 961,644 bytes, two thirds of the tagged file. A print profile is large. That alone is a reason not to pass CMYK files through to the web.

What does sharp output by default?

I ran the tagged file through five pipelines and read each output back. The last column is the mean absolute difference from the original sRGB photo, per channel, on a 0 to 255 scale:

tagged, .jpeg() default jpeg srgb channels 3 icc none 181716 B mean |diff| vs source 17.50 tagged, toColourspace srgb jpeg srgb channels 3 icc none 181716 B mean |diff| vs source 17.50 tagged, keepIccProfile jpeg srgb channels 3 icc 480 B 182214 B mean |diff| vs source 17.50 untagged, .jpeg() default jpeg srgb channels 3 icc none 181716 B mean |diff| vs source 17.50 tagged, .webp() default webp srgb channels 3 icc none 191046 B mean |diff| vs source 17.62 

Three things stand out:

  1. The default already converts. .jpeg() alone gave byte-for-byte the same file as an explicit toColourspace('srgb'). The sharp colour docs say output is "web-friendly sRGB" by default.
  2. keepIccProfile() keeps an sRGB profile, not the CMYK one. The output carries 480 bytes of profile, not 961,644. That is what you want.
  3. The 17.50 is not an error in the way back. It is what the trip sRGB to CMYK to sRGB costs: colours outside the CMYK gamut get clipped on the way in. A real print file never was sRGB, so it has no such loss to show.

The untagged file matched the tagged one because libvips falls back to the same built-in CMYK profile that wrote it. A real untagged file from Photoshop was written with some other profile, and the fallback is then a guess.

How wrong is the formula?

Converter tutorials print the formula R = 255 × (1 − C/255) × (1 − K/255), with G and B from M and Y. I applied it to the decoded CMYK values and compared it with sharp's ICC conversion of the same file:

const ref = await sharp(cmyk).toColourspace('srgb').raw().toBuffer(); const { data: c, info } = await sharp(cmyk).toColourspace('cmyk').raw().toBuffer({ resolveWithObject: true }); let sum = 0, max = 0, over10 = 0; for (let p = 0, q = 0; q < ref.length; p += 4, q += 3) { const k = 1 - c[p + 3] / 255; let worst = 0; for (let ch = 0; ch < 3; ch++) { const v = Math.round(255 * (1 - c[p + ch] / 255) * k); const d = Math.abs(v - ref[q + ch]); sum += d; max = Math.max(max, d); worst = Math.max(worst, d); } if (worst > 10) over10++; } console.log('mean', (sum / ref.length).toFixed(2), 'max', max, 'over 10:', (100 * over10 / (info.width * info.height)).toFixed(1) + '%'); 
naive vs ICC: mean 27.52 max 130 pixels with a channel off by >10: 94.9% of 480000 

Mean distance from the ICC conversion: 0 for sharp's default, 27.52 for the formula

Nearly every pixel, 94.9% of 480,000, had at least one channel more than 10 levels away. The formula ignores the profile entirely, so it misses across the whole photo, not just at the edges.

Note: The common mistake is "fixing" CMYK uploads with raw pixel math because it looks like a one-liner. The profile is what says which colour each ink mix makes. Let the decoder and the profile do the work.

The pipeline step

This is the whole step I would put in an upload worker. It converts any CMYK input to sRGB and attaches a small sRGB profile so every browser reads the colours the same way:

async function toWeb(buffer) { const { space } = await sharp(buffer).metadata(); const pipeline = sharp(buffer).rotate(); // apply EXIF orientation first if (space === 'cmyk') pipeline.toColourspace('srgb'); return pipeline.keepIccProfile().jpeg({ quality: 82, mozjpeg: true }).toBuffer(); } 

On the tagged test file, toWeb() returned an 89,228-byte JPEG: srgb, 3 channels, a 480-byte profile.

The explicit toColourspace('srgb') repeats the default. I keep it so the intent survives a future refactor that adds a pipelineColourspace() call. On my machine the convert plus encode of the 800 × 600 CMYK file took 55.0 ms (median of 5 runs, quality 90).

For the colour theory behind this, and how browsers draw an untagged CMYK file, see the CMYK to RGB guide on our site.

How I measured

  • Machine: Apple M1 Pro, macOS 15.7.3, on 2026-10-17.
  • Node 24.22.0, sharp 0.35.4, libvips 8.18.6, lcms 2.19.1.
  • Source: packages/engine/tests/fixtures/photo.jpg (800 × 600, sRGB), made CMYK by sharp with libvips's built-in CMYK profile.
  • Differences are mean absolute differences on 8-bit sRGB values, not a perceptual metric like CIEDE2000.
  • Timing is the median of 5 runs with performance.now().

What I did not test

I did not test a CMYK file from Photoshop with a SWOP or FOGRA profile, so the untagged-file fallback is shown only for libvips's own profile. I did not measure perceptual colour difference.

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.