Firebase Database & Storage Tutorial: Build an Instagram-Like Photo Feed

Building a photo-sharing feed where users upload a photo and see everyone else's, without standing up your own backend, is exactly what Firebase's Realtime Database and Storage handle. Pairing them together is one of the most common patterns in real iOS apps. This tutorial builds a simplified Instagram clone: a public photo feed with upload, caching, pull-to-refresh, and infinite scroll.

This tutorial assumes Firebase Authentication is already set up in your project, because publishing a post requires a signed-in user.

What You'll Learn

  • Building an Instagram-style camera picker with a third-party library
  • How Firebase Realtime Database's NoSQL structure differs from a traditional SQL/relational database
  • Uploading images to Firebase Storage and linking them to database records
  • Structuring code with a model + service layer instead of duplicating Firebase calls everywhere
  • Querying, ordering, and limiting results from Realtime Database
  • Caching downloaded images and fixing a common table view cell-reuse bug
  • Implementing pull-to-refresh and infinite scrolling

Step 1: Build the Camera Interface with YPImagePicker

Rather than building a custom camera UI from scratch on top of UIImagePickerController, this tutorial uses YPImagePicker, an open-source Swift library that gives you an Instagram-style photo/video picker with minimal setup.

Install it via Swift Package Manager (File → Add Package Dependencies) using its GitHub URL, then wire up a basic camera action:

import YPImagePicker

@IBAction func openCamera(_ sender: Any) {
    let picker = YPImagePicker()
    picker.didFinishPicking { [unowned picker] items, _ in
        picker.dismiss(animated: true, completion: nil)
    }
    present(picker, animated: true, completion: nil)
}

Three lines of logic (instantiate the picker, handle the selection in a completion closure, present it) are enough to get a functional photo and camera picker.

Apple requires explicit permission strings before accessing the camera or photo library. Add them to Info.plist:

  • Privacy - Camera Usage Description
  • Privacy - Photo Library Usage Description

Without these keys, the app crashes the moment it tries to request access.

Step 2: Understand How Firebase Structures Data

Before writing upload code, it helps to understand what makes Firebase Realtime Database different from something like Parse or a traditional SQL backend.

A relational database (or Parse) stores data in tables: a Post table with columns like user, image, and votes, where each record is a row. It mirrors a spreadsheet.

Firebase Realtime Database is NoSQL. There are no tables, columns, or rows. Data is stored as a single large JSON tree of key-value pairs. You don't define a schema before writing data; you just write it. A collection of posts looks like this:

{
  "posts": {
    "-KmLSIrasVfvGDsvtYs1": {
      "imageFileURL": "https://firebasestorage.googleapis.com/...",
      "timestamp": 1497172886765,
      "user": "Simon Ng",
      "votes": 0
    },
    "-KmLSNCxkCC8T2xQgL9F": {
      "imageFileURL": "https://firebasestorage.googleapis.com/...",
      "timestamp": 1497172904579,
      "user": "Simon Ng",
      "votes": 2
    }
  }
}

Each child node under posts is roughly equivalent to a row in a relational table, just represented as a JSON object with a unique, auto-generated key instead of a numeric ID. Firebase's native data types are limited to strings, numbers, dictionaries, and arrays, and there's no dedicated type for storing images or binary data directly.

Realtime Database cannot hold binary data. That is what Firebase Storage is for, a separate product built specifically for files. The pattern that ties them together:

  1. Upload the image file to Storage.
  2. Storage returns a download URL for that file.
  3. Save that URL (not the image itself) as a field in a Database record.
  4. When displaying a post, read the record from Database, then use its imageFileURL to fetch the actual image from Storage.

Step 3: Install the Firebase SDK

Add the Firebase iOS SDK via Swift Package Manager using its repository URL, then select the specific products you need: FirebaseAnalytics, FirebaseAuth, FirebaseDatabase, and FirebaseStorage. Build the project once afterward to confirm everything resolves cleanly.

In the Firebase console, set up both Storage (under Build → Storage) and Realtime Database (under Build → Realtime Database), starting each in test mode for development. Test mode intentionally allows broad read/write access and expires after a set period, so make sure to configure real security rules before shipping.

Step 4: Resize Images Before Uploading

Photos straight from the camera are often over 1MB, which slows both upload and later download for every user viewing the feed. A UIImage extension keeps this reusable across the app:

extension UIImage {
    func scale(newWidth: CGFloat) -> UIImage {
        if self.size.width == newWidth { return self }

        let scaleFactor = newWidth / self.size.width
        let newHeight = self.size.height * scaleFactor
        let newSize = CGSize(width: newWidth, height: newHeight)

        UIGraphicsBeginImageContextWithOptions(newSize, false, 0.0)
        self.draw(in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        return newImage ?? self
    }
}

Calculating the scale factor from the target width preserves the original aspect ratio, so image.scale(newWidth: 640.0) becomes a one-line call usable anywhere in the app.

Step 5: Upload an Image to Firebase Storage

Every Storage interaction starts with a reference, an entry point pointing at a specific location in your storage bucket:

Storage.storage().reference()                          // root
Storage.storage().reference().child("photos")         // a "photos" sub-folder

To upload, specify the content type via StorageMetadata, then call putData:

let metadata = StorageMetadata()
metadata.contentType = "image/jpg"

let uploadTask = imageStorageRef.putData(imageData, metadata: metadata) { metadata, error in
    // Handle success/failure here
}

Uploads run asynchronously. To track progress (useful for a progress bar or debug logging), attach an observer to the .progress event:

let observer = uploadTask.observe(.progress) { snapshot in
    let percentComplete = 100.0 * Double(snapshot.progress!.completedUnitCount) / Double(snapshot.progress!.totalUnitCount)
    print("Uploading... \(percentComplete)% complete")
}

Once the upload finishes, retrieve the file's public download URL from the task's snapshot metadata. This is the value that eventually gets saved to the Database record.

Step 6: Save Post Data to Firebase Database

Writing to the Database follows the same reference pattern as Storage:

let postDatabaseRef = Database.database().reference().child("posts")

Rather than manually generating unique IDs, Firebase provides childByAutoId() to generate one for you and return a reference to that new location:

let postDatabaseRef = Database.database().reference().child("posts").childByAutoId()

let post: [String: Any] = [
    "imageFileURL": imageFileURL,
    "votes": Int(0),
    "user": displayName,
    "timestamp": timestamp
]

postDatabaseRef.setValue(post)

Reusing that same auto-generated key as the image's filename in Storage ("\(imageKey).jpg") neatly ties the two products together: the Storage file and Database record share an identifier, even though they live in separate systems.

Step 7: Refactor Into a Model and a Service Class

Database and storage references and the post dictionary structure would otherwise get duplicated across every screen that touches Firebase, a maintenance risk if a field name ever needs to change. Two structural changes fix this:

A Post model that converts a raw dictionary into a strongly typed object, with Firebase's field names centralized as constants instead of hardcoded strings scattered everywhere:

struct Post {
    var postId: String
    var imageFileURL: String
    var user: String
    var votes: Int
    var timestamp: Int

    enum PostInfoKey {
        static let imageFileURL = "imageFileURL"
        static let user = "user"
        static let votes = "votes"
        static let timestamp = "timestamp"
    }

    init?(postId: String, postInfo: [String: Any]) {
        guard let imageFileURL = postInfo[PostInfoKey.imageFileURL] as? String,
              let user = postInfo[PostInfoKey.user] as? String,
              let votes = postInfo[PostInfoKey.votes] as? Int,
              let timestamp = postInfo[PostInfoKey.timestamp] as? Int else { return nil }

        self.postId = postId
        self.imageFileURL = imageFileURL
        self.user = user
        self.votes = votes
        self.timestamp = timestamp
    }
}

A PostService singleton that centralizes every Database/Storage reference and every upload/download method in one place, so no other class talks to Firebase directly:

final class PostService {
    static let shared: PostService = PostService()
    private init() { }

    let POST_DB_REF: DatabaseReference = Database.database().reference().child("posts")
    let PHOTO_STORAGE_REF: StorageReference = Storage.storage().reference().child("photos")

    func uploadImage(image: UIImage, completionHandler: @escaping () -> Void) {
        // Resize, upload to Storage, then write the record to Database
        // (same logic as Steps 4-6, consolidated here)
    }
}

The private init() combined with a static shared property is the standard Singleton pattern, the same approach the iOS SDK itself uses for UserDefaults.standard and URLSession.shared. It guarantees the entire app talks to exactly one PostService instance. From here, uploading a photo anywhere in the app is a single call: PostService.shared.uploadImage(image: photo.image) { ... }.

Step 8: Query and Retrieve Posts

Reading data uses event-based methods. observeSingleEvent fires once, observe fires repeatedly on every change. A basic read:

POST_DB_REF.observeSingleEvent(of: .value, with: { snapshot in
    for item in snapshot.children.allObjects as! [DataSnapshot] {
        let postInfo = item.value as? [String: Any] ?? [:]
        // process postInfo
    }
})

For a real feed, you need ordering and limits, not just a raw dump:

// Order by timestamp
POST_DB_REF.queryOrdered(byChild: "timestamp")

// Limit to the most recent N. Firebase sorts ascending,
// so the most recent items are the *last* N, not the first N
POST_DB_REF.queryOrdered(byChild: "timestamp").queryLimited(toLast: 5)

Firebase only sorts ascending, so if you think of the results as an array, the oldest post is first and the newest is last. This means queryLimited(toLast:) gets you the newest posts, not queryLimited(toFirst:).

Loading only newer posts (for pull-to-refresh) combines a starting value with the limit:

postQuery = postQuery
    .queryStarting(atValue: latestPostTimestamp + 1, childKey: "timestamp")
    .queryLimited(toLast: limit)

queryStarting(atValue:) restricts results to records with a timestamp greater than the value you pass, so passing latestPostTimestamp + 1 returns only posts newer than the last one you already have.

Step 9: Cache and Display Images in the Table View

Downloading the same image repeatedly as cells scroll on and off screen wastes bandwidth and time. NSCache, a built-in key-value cache designed for exactly this, solves it with a small wrapper class:

final class CacheManager {
    static let shared: CacheManager = CacheManager()
    private static var cache: NSCache<NSString, AnyObject> = {
        let cache = NSCache<NSString, AnyObject>()
        cache.countLimit = 100
        cache.totalCostLimit = 1024 * 1024 * 50 // 50MB
        return cache
    }()
    private init() { }

    func cache(object: AnyObject, key: String) {
        CacheManager.cache.setObject(object, forKey: key as NSString)
    }

    func getFromCache(key: String) -> AnyObject? {
        return CacheManager.cache.object(forKey: key as NSString)
    }
}

In each table view cell's configuration method, check the cache before hitting the network:

if let image = CacheManager.shared.getFromCache(key: post.imageFileURL) as? UIImage {
    photoImageView.image = image
} else {
    // Download via URLSession, then cache the result once it arrives
}

Step 10: Fix the Cell-Reuse Image Flicker Bug

Table views reuse cells for performance. If a user scrolls quickly, a cell might start downloading image A, get reused for a different row before that download finishes, start downloading image B, and then image A's download completes after the cell has already moved on, briefly overwriting image B with the wrong photo.

The fix is a simple identity check before applying any downloaded image:

private var currentPost: Post?

func configure(post: Post) {
    currentPost = post
    // ... download logic ...
}

// Inside the download's completion handler:
if self.currentPost?.imageFileURL == post.imageFileURL {
    self.photoImageView.image = image
}

By recording which post the cell is currently supposed to display, and re-checking that at the moment the download completes, a stale download simply gets discarded instead of visibly flickering the wrong image onto the screen.

Step 11: Add Pull-to-Refresh

Standard UIRefreshControl wiring, plus a call to reload posts:

override func viewDidLoad() {
    super.viewDidLoad()
    refreshControl = UIRefreshControl()
    refreshControl?.addTarget(self, action: #selector(loadRecentPosts), for: .valueChanged)
    loadRecentPosts()
}

loadRecentPosts must be marked @objc for the #selector reference to compile, since selectors rely on the Objective-C runtime and Swift methods are not exposed to it by default.

Step 12: Implement Infinite Scrolling

Most modern feeds load more content automatically as the user approaches the bottom, rather than showing a "Load More" button. The tableView(_:willDisplay:forRowAt:) delegate method fires right before each cell is about to be drawn, making it the natural trigger point:

override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    guard !isLoadingPost, postfeed.count - indexPath.row == 2 else { return }

    isLoadingPost = true
    guard let lastPostTimestamp = postfeed.last?.timestamp else {
        isLoadingPost = false
        return
    }

    PostService.shared.getOldPosts(start: lastPostTimestamp, limit: 3) { newPosts in
        // Append newPosts to postfeed and insert corresponding table view rows
        self.isLoadingPost = false
    }
}

Triggering the load when the user is two rows from the end (instead of exactly at the last row) gives the fetch a head start, so new content is more likely to be loaded by the time the user reaches it. getOldPosts uses queryEnding(atValue:), the mirror image of queryStarting(atValue:), to fetch posts older than the oldest one currently displayed.

Step 13: Index Your Query Field

Once your app is running real queries ordered by timestamp, Firebase logs a performance warning until you explicitly index that field. In the Firebase console, under Realtime Database → Rules, add:

{
  "rules": {
    "posts": {
      ".indexOn": ["timestamp"]
    }
  }
}

Without this, Firebase downloads and filters data client-side for every query. It works fine at small scale, but the performance cost grows as your posts node grows. Indexing tells Firebase to optimize specifically for queries ordered by that field.

Summary

A Firebase-powered photo feed comes down to a repeating pattern: upload a file to Storage, save its URL alongside metadata in Database, and query that Database with ordering and limits to build a feed. Adding a model struct and a centralized service class keeps that pattern from getting duplicated across the app, while caching, cell-reuse guards, pull-to-refresh, and infinite scroll round it out into something that behaves like a real production feed rather than a bare-bones demo.

Frequently Asked Questions

Why can't I just store images directly in Firebase Realtime Database? Realtime Database only supports strings, numbers, dictionaries, and arrays. It has no binary data type. Firebase Storage is the dedicated product for files like images and videos; Database only stores the resulting download URL.

Why does queryLimited(toLast:) return the newest posts instead of the oldest? Firebase only sorts data in ascending order. If you picture query results as an array ordered oldest to newest, the most recent items are naturally the last ones in that array, so "last N" means "most recent N."

Do I need a service class and model struct, or can I call Firebase directly from my view controllers? You can call Firebase directly, but every additional screen that needs upload and download logic will duplicate reference paths and dictionary keys. A centralized service class and model struct mean a field name or path only needs to change in one place.

What causes the wrong image to briefly appear in a table view cell while scrolling? Cell reuse combined with asynchronous image downloads. A cell can be reassigned to a new row before its previous download finishes, so the old download's completion handler needs to verify the cell still wants that specific image before applying it.

All articles