Keeping Asset URLs Deterministic Across ImageKit and R2
Why this matters
A Markdown publisher should not care whether an optimized diagram lands in
ImageKit or Cloudflare R2. It should hand a storage adapter one object key and
receive one durable public URL.
That small interface hides meaningful differences.
ImageKit has an upload API that can overwrite a known path when unique
filenames are disabled. R2 exposes an S3-compatible API, so a publisher can
check an object with HeadObject before deciding whether to upload it. Their
authentication, folder semantics, public delivery configuration, and failure
modes are not interchangeable.
The goal is therefore not to pretend the providers are identical. It is to
keep the provider-neutral identity stable while making provider-specific
recovery behavior explicit.
I traced both adapters in a real TypeScript publisher and ran an isolated
experiment that captured the ImageKit form and R2 S3 commands without sending
anything over the network.
What I built or tested
The common contract is deliberately small:
interface StorageProvider {
readonly name: string;
exists(objectKey: string): Promise<boolean>;
upload(params: {
localFile: string;
objectKey: string;
contentType: string;
}): Promise<string>;
publicUrl(objectKey: string): string;
}
I tested:
- default and explicit provider selection;
- required configuration for each provider;
- public URL construction with spaces in an object key;
- the multipart fields sent to ImageKit;
- R2
HeadObjectandPutObjectcommand construction; - R2 behavior for found, 404, and non-404 outcomes; and
- the repository's focused ImageKit URL test.
The experiment used fake configuration, an in-memory fetch replacement, and
a local S3 command recorder. It did not contact ImageKit or R2, so it does not
claim that credentials, bucket policy, caching, or remote delivery were
verified.
Setup
Provider choice is configuration-driven. ImageKit is the default:
STORAGE_PROVIDER=imagekit
IMAGEKIT_URL_ENDPOINT=https://ik.imagekit.io/example
IMAGEKIT_PRIVATE_KEY=...
IMAGEKIT_FOLDER=/blog_img
R2 must be selected explicitly:
STORAGE_PROVIDER=r2
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=assets
R2_PUBLIC_BASE_URL=https://assets.example.com
The factory validates provider-specific requirements before constructing an
adapter. That keeps a missing public base URL or credential from surfacing
halfway through image processing.
Notice that R2 needs two different endpoint concepts:
- the account-specific S3 API endpoint for authenticated object operations;
- a configured public base URL for readers.
Successful PutObject does not, by itself, prove that the resulting public
URL is reachable.
Step-by-step walkthrough
1. Keep one provider-neutral object key
The upstream image pipeline supplies keys shaped like:
blog/article-slug/diagram-contenthash.webp
Both adapters consume that same key. Their hostnames and API calls differ, but
the path identity does not. This is the most useful portability boundary:
switching providers changes where the bytes live, not how the article refers
to the logical asset.
Both publicUrl() methods encode each path segment independently. In the
experiment, a key containing diagram 1.webp became
diagram%201.webp for both providers. Encoding segments instead of an entire
URL preserves / as hierarchy while protecting spaces and other path
characters.
2. Route through a strict factory
The storage factory first checks configuration, then chooses an adapter:
return config.STORAGE_PROVIDER === "r2"
? new R2Storage(config)
: new ImageKitStorage(config);
My experiment observed ImageKit for default configuration and R2 only when
STORAGE_PROVIDER=r2 was explicit. It also observed the complete missing-field
list for each provider.
This is more useful than allowing a half-configured fallback. If CI intends to
write to R2, silently selecting ImageKit would create valid URLs in the wrong
account.
3. Make ImageKit overwrite semantics deliberate
The ImageKit adapter constructs a multipart upload with:
fileName=diagram.webp
folder=/blog_img/blog/demo
useUniqueFileName=false
The current
ImageKit upload documentation
states that useUniqueFileName=false keeps the supplied filename and replaces
an existing file at the same name. That behavior is important because the
adapter deliberately returns false from exists().
Its recovery model is:
- trust the local image table as the durable deduplication index;
- when that record is unavailable, upload the deterministic path again;
- overwrite that path instead of creating a suffixed duplicate.
The successful upload response can supply the delivery URL. If it omits one,
the adapter can derive the URL from its configured endpoint, folder, and
object key.
4. Let R2 check the object remotely
R2 takes a different path:
One object identity, two explicit recovery strategies.
The R2 adapter sends HeadObject to its configured bucket. A 404 means the
object is absent. Other errors are rethrown rather than treated as a cache
miss.
That distinction prevents an authorization failure or service problem from
silently becoming a write. “I could not check” is not the same as “the object
does not exist.”
When upload is needed, the adapter sends PutObject with the key, content
type, and:
Cache-Control: public, max-age=31536000, immutable
Cloudflare documents R2's
S3-compatible endpoint and operations,
including HeadObject, and provides
JavaScript PutObject examples.
What went wrong
The provider interface initially suggests symmetric behavior, but exists()
does not mean the same thing in both adapters.
For R2, it is a real remote existence check. For ImageKit, it always returns
false; the comment explains that the local database is the durable index and
deterministic overwrite is the fallback.
That asymmetry is intentional, but it is easy to miss in a code review. A
generic caller that assumes every exists() asks the remote provider could
draw the wrong conclusions about traffic, cost, or failure recovery.
The test gap makes the risk larger. The repository has one direct storage test
for ImageKit folder placement and encoded URLs. It has no direct R2 adapter
test for 404 classification, non-404 rethrow, upload metadata, or URL encoding.
There is also a deployment boundary that local command construction cannot
verify. R2 buckets are private by default. Cloudflare's
public bucket documentation
describes custom domains and the r2.dev development endpoint, and recommends
custom domains for production features. A correct R2_PUBLIC_BASE_URL still
requires the matching bucket exposure to be configured.
Fix or mitigation
I would keep the small interface, but add an adapter contract suite with
provider-specific cases.
| Contract case | ImageKit expectation | R2 expectation |
|---|---|---|
| encoded public path | endpoint + folder + encoded key | public base + encoded key |
| known local record | caller skips provider work | caller skips provider work |
| storage existence check | returns false by design | sends HeadObject
|
| missing remote object | deterministic overwrite upload | 404 then PutObject
|
| authorization/service error | upload response must fail | non-404 HEAD error rethrows |
| upload result | response URL or derived fallback | derived public base URL |
The suite should use fake transports, as my experiment did, so it can inspect
requests without credentials. A separate integration check can then verify one
real upload in a disposable prefix for each configured provider.
I would also rename or document the existence capability more explicitly if
more adapters are added. A method such as remoteExists() communicates more
than a generic exists(), while an adapter capability flag could make
database-led recovery visible to the caller.
Trade-offs
ImageKit's deterministic overwrite path is simple and works even when the
local deduplication record is lost. The cost is that recovery performs an
upload rather than a remote existence query.
R2's HEAD-before-PUT path can recover an existing object without uploading it.
It adds another remote operation and requires careful error classification. It
also separates S3 write access from public delivery configuration.
Immutable cache headers fit content-addressed object keys, because changed
bytes should produce a changed key. They are dangerous if mutable content is
reused at the same key. The cache policy and naming policy must therefore be
reviewed together.
Finally, deterministic does not mean identical across providers. The hostname
and provider folder prefix differ. The invariant is the encoded logical object
path and repeatable mapping within each configured provider.
How I verified it
The isolated experiment produced these key observations:
{
"factoryDefaultsTo": "imagekit",
"factorySelectsR2": "r2",
"imageKitExists": false,
"imageKitUseUniqueFileName": "false",
"r2Exists": true,
"r2NotFound": false,
"r2ServerErrorRethrown": true,
"r2Commands": [
"HeadObjectCommand",
"PutObjectCommand"
]
}
It also verified that both adapters encoded a space as %20, that the
ImageKit folder contained the configured prefix exactly once, and that the R2
put command carried the expected bucket, key, content type, and immutable
cache header.
The focused ImageKit test passed. Before release, I additionally ran the
article validator, Mermaid rendering and visual inspection, publisher dry-run,
TypeScript typecheck, and the full repository test suite.
No remote provider was contacted during the experiment. A real R2 public
domain and real ImageKit authorization remain integration concerns, not
claims made by this article.
Conclusion
A useful storage abstraction does not erase provider differences. It gives
those differences a narrow place to live.
Keep one object key across adapters. Validate configuration before processing.
Encode public paths consistently. Then test each provider's actual recovery
model: deterministic overwrite for ImageKit, HEAD-before-PUT for R2, and hard
failure when an existence result is ambiguous.
That is enough to let the Markdown publisher stay provider-neutral without
pretending its storage systems behave the same.
AI assistance disclosure
I used an AI coding assistant to trace the storage adapters, prepare the
isolated transport experiment, compare provider behavior with official
ImageKit and Cloudflare documentation, and edit the draft. I reviewed the
cited source, executed the reported checks, inspected the rendered diagram,
and kept unexecuted remote behavior explicitly labeled as a limitation.

Top comments (0)