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:
- The default already converts.
.jpeg()alone gave byte-for-byte the same file as an explicittoColourspace('srgb'). The sharp colour docs say output is "web-friendly sRGB" by default. -
keepIccProfile()keeps an sRGB profile, not the CMYK one. The output carries 480 bytes of profile, not 961,644. That is what you want. - 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 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.




