Why your Base44 image upload is not working
Base44 image uploads fail silently for five distinct reasons that all present the same way in the UI: the file exceeds the 10MB platform limit, the MIME type is outside the storage bucket's allowlist, an RLS policy blocks the storage record insert, the upload helper times out and drops the returned URL, or the upload succeeds but the post-upload database write that links the URL to the parent record is missing. The five fixes are: validate size and MIME client-side, compress and convert HEIC to JPEG before upload, audit the bucket's RLS predicates against the actual payload, await and inspect the full upload response, and persist the returned URL to your entity table inside the same handler. The Network tab response code (200, 403, 413) isolates which of the five is biting in under two minutes.
You wire up an image upload in your Base44 app. The agent scaffolds an upload component, a file picker, an upload handler. In testing, the file picker opens, you select an image, the button shows a loading spinner, and then... nothing. No image. No error. The form returns to its default state as if you never tried. Or worse: the upload says it succeeded, but the image never appears anywhere the next time you load the page.
This is one of the most common Base44 bugs in production, and it has five distinct root causes that all manifest as the same blank UI. The fix depends on which root cause is biting, and you cannot guess — you have to look at the Network tab response.
The five causes, in rough order of frequency:
- File exceeds the 10MB platform ceiling. Photos from modern phones routinely exceed this. The rejection happens at the platform edge and the agent-generated handler usually does not surface the error.
- MIME type rejected. HEIC (iPhone default), HEIF, AVIF, and TIFF are not in the default allowlist. The browser reports the MIME correctly; the server rejects it.
- RLS policy blocks the insert. The storage bucket has a row-level security predicate, and your client either is not authenticated or is not sending the columns the predicate checks.
- Upload helper drops the URL. The upload itself succeeds, but the SDK helper times out or hits a version mismatch and returns a response object without the
urlfield. - Post-upload database write is missing. The file is in the bucket. The URL is returned. But the code never writes that URL to the entity record, so the next render shows the empty placeholder.
Each of these needs a different fix. The diagnostic phase — reproduce in DevTools, inspect the Network response, check the storage console — takes under two minutes and tells you exactly which fix to apply.
What causes Base44 image upload failures
Base44's storage layer is a thin wrapper over a Supabase-style bucket with row-level security policies, MIME allowlists, and size ceilings configured per bucket. The agent generates upload code that handles the happy path — file picker, upload call, optimistic UI update — but typically does not handle the four failure modes that show up in production.
Platform size limit. The default storage tier ceiling is 10MB per file. The platform enforces this at the edge before your handler sees the file. The response is a 413 Payload Too Large, but the agent-generated code rarely surfaces it. Photos taken on a recent iPhone or Android camera are commonly 8-15MB; HEIC files can be smaller, but uncompressed JPEG is regularly over the limit.
MIME allowlist. Storage buckets are configured with an accepted MIME type list. The default includes image/jpeg, image/png, image/webp, image/gif — most browser-friendly image formats. It typically excludes image/heic, image/heif, image/tiff, and sometimes image/avif. iPhone photos default to HEIC, and the browser File API reports the MIME correctly, so the upload fires with Content-Type: image/heic and the server rejects it with a 415 Unsupported Media Type.
Row-level security. If the bucket has RLS enabled (the recommended production setting), an INSERT policy gates every upload. A common policy is auth.uid() IS NOT NULL (must be authenticated) or auth.uid() = owner_id (must be the owner). If your client is unauthenticated, the upload returns 403. If the client is authenticated but the payload does not include the columns the policy references, the upload also returns 403 with a body like {"error": "new row violates row-level security policy"}.
Response URL dropped. The Base44 SDK's upload helper returns a Promise that resolves with { url, path, error }. If the helper hits a timeout, a transient network error, or an SDK version mismatch, it may resolve with { url: null, path: null, error: "..." } — a successful Promise resolution but no usable URL. Code that does not inspect the full response treats this as success and renders an empty image tag.
Missing database write. The upload is two steps: write the file to storage, then write the URL to your entity record. The agent often scaffolds step one and forgets step two. The file lands in the bucket, the URL is returned, the function returns successfully, but the User row's avatar_url field is never updated. The next render queries the user, finds no avatar URL, and shows the placeholder.
Each of these has a distinct Network tab signature:
| Symptom | Network response | Root cause |
|---|---|---|
| Upload spinner forever | Request stuck in pending | CORS or connectivity issue |
| 413 status | { error: "..." } | File over 10MB |
| 415 status | { error: "..." } | MIME not in allowlist |
| 403 status | { error: "RLS" } | Storage policy rejection |
| 200 with no URL | { url: null } | SDK helper timeout |
| 200 with URL, no image | nothing visible after reload | Missing entity update |
How to confirm Base44 image upload is broken (reproduction)
- Open your Base44 app in Chrome or Firefox. Open DevTools (F12). Switch to the Network tab. Filter to "Fetch/XHR" to cut noise.
- Attempt the upload that is failing. Pick the image file you would normally use.
- Watch the Network tab. Identify the request that fires to the storage endpoint — usually a POST to a URL containing
/storage/or/upload/. - Click that request. Note the status code (200, 403, 413, 415) and look at the Response tab for the body content.
- If the status is 200, check the response body for a
urlfield. If the URL is present, switch to step 6. If null or missing, the SDK helper failed — go to the post-upload URL section below. - Open a new tab and paste the URL from the response. If the image loads, the upload worked. If the URL 403s, the bucket is private. If the image loads in a new tab but not in your app, the entity write is missing — query your database/entity console to confirm the URL was never persisted.
- In the Base44 console, navigate to Storage → your bucket. Look for the file. If it is there, the upload itself succeeded. If not, return to step 4 with the response code in hand.
How to fix Base44 image upload not working
Fix 1: Add client-side size and type validation
Before the upload call fires, validate. Convert silent platform rejections into actionable UI errors.
const MAX_BYTES = 9 * 1024 * 1024; // 9MB leaves headroom for multipart overhead
const ALLOWED_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
function validateImageFile(file: File): string | null {
if (file.size > MAX_BYTES) {
return `File is ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum is 9MB.`;
}
if (!ALLOWED_TYPES.has(file.type)) {
return `${file.type} is not supported. Use JPEG, PNG, WebP, or GIF.`;
}
return null;
}
async function handleUpload(file: File) {
const error = validateImageFile(file);
if (error) {
setError(error);
return;
}
// proceed with upload
}
Use 9MB not 10MB as the client threshold. The platform's 10MB ceiling is on the wire bytes (multipart-encoded), not the raw file. A 9.8MB raw file can encode to 10.1MB and fail.
Fix 2: Compress and convert before upload
Install browser-image-compression and route every upload through it. This eliminates size failures for camera photos and gives you control over quality.
import imageCompression from "browser-image-compression";
async function compressForUpload(file: File): Promise<File> {
const options = {
maxSizeMB: 2,
maxWidthOrHeight: 1920,
useWebWorker: true,
fileType: "image/jpeg" as const,
};
return imageCompression(file, options);
}
For HEIC support, add heic2any and run conversion before compression:
import heic2any from "heic2any";
async function normalizeFile(file: File): Promise<File> {
if (file.type === "image/heic" || file.type === "image/heif") {
const blob = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.85 });
const converted = Array.isArray(blob) ? blob[0] : blob;
return new File([converted], file.name.replace(/\.heic$/i, ".jpg"), {
type: "image/jpeg",
});
}
return file;
}
async function handleUpload(rawFile: File) {
const normalized = await normalizeFile(rawFile);
const compressed = await compressForUpload(normalized);
const error = validateImageFile(compressed);
if (error) {
setError(error);
return;
}
const result = await entities.Storage.upload(compressed);
// see fix 4
}
This pipeline handles every camera photo, every HEIC iPhone export, and every oversize PNG with no user-visible failure.
Fix 3: Audit storage RLS policies
In the Base44 console, navigate to Storage → your bucket → Policies. Read the INSERT policy carefully. Common patterns:
-- Authenticated users only
auth.uid() IS NOT NULL
-- Owner-scoped (payload must include user_id)
auth.uid() = (storage.foldername(name))[1]::uuid
-- Tenant-scoped (payload must include tenant_id)
EXISTS (
SELECT 1 FROM tenant_members
WHERE tenant_id = (storage.foldername(name))[1]::uuid
AND user_id = auth.uid()
)
If the policy references a column or path segment that your upload payload does not include, the insert fails with 403. The fix is one of two things: change the policy to match your payload, or change your payload to satisfy the policy. Owner-scoped policies typically expect the file path to start with the user ID (e.g., userId/avatar.jpg), so the upload call needs the path argument:
const path = `${currentUser.id}/${Date.now()}-${file.name}`;
const result = await entities.Storage.upload(file, { path });
Fix 4: Await the response and check for the URL
Never assume an upload succeeded just because the promise resolved. Inspect the response.
async function handleUpload(file: File) {
try {
const result = await entities.Storage.upload(file);
console.log("upload result:", result); // log the full object
if (!result || !result.url) {
throw new Error(
result?.error ?? "Upload succeeded but no URL was returned. SDK helper issue."
);
}
// result.url is the public storage URL; persist it (fix 5)
return result.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
throw err;
}
}
The log line is non-negotiable during debugging. If the upload returns { url: null, error: "timeout" }, you need to know that — not assume the absence of a thrown exception means success.
Fix 5: Persist the URL to your entity record
The file is in storage. You have a URL. Now write it to the row that should reference it.
async function uploadAvatar(file: File) {
const url = await handleUpload(file);
if (!url) return;
await entities.User.update(currentUser.id, {
avatar_url: url,
});
// optimistic UI update so the user sees it immediately
setUser({ ...currentUser, avatar_url: url });
}
The two writes (storage + entity) should be in the same handler. If they get split across components or hooks, you will eventually ship a flow where one runs and the other does not, and you will spend an afternoon debugging why some users have orphan files in the bucket and a null avatar_url on the record.
Fix 6: Add progress UI and an error boundary
Silent failure is the worst UX. Every upload should show a progress indicator and resolve into either a success state with the image or a failure state with a specific message.
function UploadButton() {
const [status, setStatus] = useState<"idle" | "uploading" | "error" | "done">("idle");
const [error, setError] = useState<string | null>(null);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setStatus("uploading");
setError(null);
try {
await uploadAvatar(file);
setStatus("done");
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
setStatus("error");
}
}
return (
<div>
<input type="file" accept="image/*" onChange={onChange} disabled={status === "uploading"} />
{status === "uploading" && <p>Uploading...</p>}
{status === "error" && <p className="text-red-600">{error}</p>}
{status === "done" && <p>Upload complete.</p>}
</div>
);
}
For very large files (close to the 9MB ceiling after compression), wire the XHR progress event into a progress bar. Users tolerate slow uploads; they do not tolerate uploads that look frozen.
How long does it take to fix Base44 image upload not working?
Timeline depends on which of the five root causes is biting and whether you fix them all defensively or just the one in front of you.
- Single-cause fix (size, MIME, RLS, response, or entity write): 15-30 minutes. Reproduce in DevTools, identify the response code, apply the matching fix from the section above.
- Defensive rewrite (all five fixes applied): 1-2 hours. Add the validation, compression, HEIC conversion, response inspection, entity update, and progress UI as a single hardened upload pipeline. Worth doing once; reuse across every upload in the app.
- Storage migration (Base44 → Cloudinary or similar): 4-8 hours for a small app, 1-3 days for an app with thousands of existing files. Only needed if you have outgrown Base44 storage on volume, format conversion, or CDN delivery requirements.
In all cases, deploy the defensive rewrite once and apply it to every upload surface. The five root causes recur across every upload in your app — fixing them in one place and importing the helper everywhere prevents the bug from coming back in a different component.
DIY vs hire decision
DIY this if: You can reproduce the failure in DevTools, you can read the Network response, and you can edit a TypeScript handler. All five fixes above are 5-30 lines of code each. The diagnosis is the hard part, and the diagnostic procedure in this guide gets you there in two minutes.
Hire help if: Uploads were working and broke after a Base44 platform update, RLS policies were configured by someone who is no longer on the team, or you have a backlog of orphan files in storage that need to be reconciled with database rows. These three scenarios involve cross-cutting changes to platform config, RLS, and historical data that benefit from a structured engagement rather than a one-off patch.
Need this fixed in the next 24 hours?
Our fix-sprint engagement covers the full upload diagnostic and hardening: Network-tab reproduction, identification of the failing root cause, defensive rewrite of the upload pipeline, RLS policy audit, and a backfill script for orphan storage records if needed. Most upload bugs ship a fix within a single afternoon.
Start a fix-sprint for image upload issues
Related problems
- Data loss when returning to the app — the sibling state-loss bug that often hides behind "uploads not working" reports.
- Data binding undefined field — the entity-write half of the upload pipeline, when the URL field is misnamed or missing from the schema.
- Rate limit 429 production throttle — uploads at scale can trigger storage-side rate limits that look like silent failures.