Bugfender https://bugfender.com App Logging & Error Monitoring Tool Thu, 05 Mar 2026 17:44:09 +0000 en-US hourly 1 https://wordpress.org/?v=6.9.4 SwiftUI Previews: Tips to Boost Your Xcode Workflow https://bugfender.com/blog/swiftui-preview/ Thu, 12 Mar 2026 10:00:03 +0000 https://bugfender.com/?p=12875 Continue reading "SwiftUI Previews: Tips to Boost Your Xcode Workflow"]]> SwiftUI Previews show us how our app will look out in the wild and let us make changes in real time, without emulators. But that’s not the full story.

The full benefit of SwiftUI Previews lies in declarativeUI, which allows us to dictate the final state we want to achieve and handles all the process stuff itself. This is a game-changer for developers, allowing us to shift our focus from ‘how’ to ‘what’.

Instead of rebuilding and relaunching your app every time you tweak a view, SwiftUI Previews let you see those changes instantly — right inside Xcode. Less time wasted on duplication, more time for the creative stuff that will set your app apart.

The release of XCode16 in September 2024 brought huge advances in SwiftUI—so it’s even more crucial that we know how to maximize the technology. In this post we’re going to take you right to the core, giving you all the tools you need to use SwiftUI

Introduction: What are SwiftUI previews, really?

The role of previews in Swift

Simply, a SwiftUI preview is an instance of your view that’s rendered in Xcode’s canvas. It runs in a lightweight simulator and lets you interact with UI elements, test different configurations and quickly validate design decisions.

Previews serve as your visual feedback loop, bridging the gap between design and implementation. Whether you’re adjusting padding, testing light and dark modes, or validating accessibility settings, SwiftUI previews help you iterate at lightning speed — without leaving your IDE.

SwiftUI / UIKit Previews are the future of interface design. Abdul Karim, seasoned iOS developer, writing on Medium in 2023.

The SwiftUI preview feature in Xcode 16

With Xcode 16, SwiftUI previews have become even more seamless and powerful.

Apple introduced a faster, more memory-efficient preview engine, meaning fewer rebuilds and smoother live updates. The new Dynamic Preview Refresh feature automatically updates your canvas when you edit code, assets, or even localization files.

Xcode 16 also enhances preview controls — you can now change orientation, device type, and appearance right from the preview inspector (fear not, we’ll come to this). Combined with the new #Preview macro introduced in Swift 5.9, previews are easier to declare, read, and maintain.

How can SwiftUI Previews benefit my app?

SwiftUI Previews drastically reduce iteration time by allowing immediate feedback on UI changes. Here are some specific benefits.

  • Rapid iteration: You can tweak your layout or animation and see the result instantly.
  • Better code organization: Each view gets its own preview, serving as a live demo and documentation.
  • Design validation: Test spacing, scaling and alignment scales across multiple devices and dynamic type sizes.
  • Easier testing: You can simulate mock data or error states without relying on a live backend.
  • Improved collaboration: Designers and PMs can visually inspect your work directly in Xcode.

For complex apps, previews function almost like UI unit tests — ensuring every view renders and behaves correctly under different conditions.

Exciting, right? Let’s go under the hood and start implementing.

Part 1: How to get started with SwiftUI previews

How to label previews

When you’re creating multiple preview configurations for a single view, it’s essential to keep them organized. Use .previewDisplayName() to label your previews for clarity.

struct ProfileCard_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ProfileCard(user: .mockStandard)
                .previewDisplayName("Standard User")

            ProfileCard(user: .mockPremium)
                .previewDisplayName("Premium User")
        }
    }
}

Labeled previews make it easier to distinguish between variations, especially in complex UI components.

Creating a super-simple preview

Let’s start with the “hello world” of SwiftUI previews. A minimal setup that displays your view instantly in the canvas.

With Swift 5.9 and Xcode 16, the #Preview macro has replaced the older PreviewProvider boilerplate.

struct WelcomeView: View {
    var body: some View {
        Text("Welcome to SwiftUI!")
            .font(.largeTitle)
            .padding()
    }
}

#Preview {
    WelcomeView()
}

Pro tip: Use the “Resume” button (or Option + Command + P) to refresh previews quickly after making code changes.

How to work with views that require parameters

Many views take parameters, like model data or configuration options. You can still preview them by providing mock values.

struct UserProfileView: View {
    var user: User

    var body: some View {
        VStack {
            Text(user.name)
                .font(.headline)
            Text(user.role)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

#Preview("Mock Data") {
    UserProfileView(user: .mock)
}

Using mock models or static data lets you simulate real-world states without depending on APIs or database connections.

How to work with views that require bindings

A @binding property establishes a two-way connection between your view and a piece of state data, enabling the view to both read and write to that particular data source.

When your view expects a @binding, SwiftUI previews allow you to use .constant().

struct SettingsView: View {
    @Binding var notificationsEnabled: Bool

    var body: some View {
        Toggle("Enable Notifications", isOn: $notificationsEnabled)
            .padding()
    }
}

#Preview("Enabled") {
    SettingsView(notificationsEnabled: .constant(true))
}

#Preview("Disabled") {
    SettingsView(notificationsEnabled: .constant(false))
}

This approach makes it easy to test both toggled states without needing real state management.

Using the UIViewController

This part is particularly important.

The UIViewController acts as a bridge between the SwiftUI view hierarchy and UIKit, Apple’s original framework for building user interfaces which has been around since the dawn of the iPhone. UIKit is essential to your technology, simulator and interface builder.

SwiftUI previews can render UIKit view controllers using UIViewControllerRepresentable , as this code shows:

struct LoginViewControllerPreview: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> some UIViewController {
        LoginViewController() // UIKit controller
    }

    func updateUIViewController(_ vc: UIViewController, context: Context) {}
}

#Preview("UIKit Login") {
    LoginViewControllerPreview()
}

This is particularly useful when you’re migrating from UIKit to SwiftUI or maintaining hybrid projects.

Part 2: More advanced use cases for SwiftUI Previews

Now let’s go through the gears. SwiftUI Previews empower us to try all kinds of layouts and orientations, enabling us to carry out A/B testing on our UX.

Quickly testing different layouts

If your layout adapts to different conditions — for instance, compact vs. regular size classes — you can easily preview both versions side by side.

#Preview("Compact Layout") {
    DashboardView()
        .previewLayout(.sizeThatFits)
}

#Preview("Full Layout") {
    DashboardView()
        .previewLayout(.device)
}

.sizeThatFits lets you see just the view’s intrinsic size, which is ideal for reusable components.

Experimenting with different view sizes

When working with dynamic layouts, it’s helpful to test specific dimensions. You can simulate various screen sizes or container widths to ensure responsive layouts work correctly.

Here’s some code to help you do that:

#Preview("Custom Size") {
    CardView()
        .previewLayout(.fixed(width: 300, height: 200))
}

Adjusting for light and dark modes

Up to 70% of Apple users have their devices in dark mode, so testing for dark environments is a non-negotiable.

SwiftUI Previews allow you to preview both light and dark appearances simultaneously and toggle appearance directly from Xcode’s preview inspector, which saves even more time.

#Preview("Light Mode") {
    ContentView()
        .preferredColorScheme(.light)
}

#Preview("Dark Mode") {
    ContentView()
        .preferredColorScheme(.dark)
}

Adjusting orientation

Orientation bugs often sneak in during development. Previews make it simple to test both portrait and landscape modes, which is especially useful for iPad apps and multi-scene layouts (we’ll delve deeper into multi-device views in the next section).

#Preview("Landscape") {
    ContentView()
        .previewInterfaceOrientation(.landscapeLeft)
}

3. How to use SwiftUI Previews across multiple devices

It goes without saying that your app is going to be used in several different contexts. iPhone Pro, iPad Air, Apple Watch… each with their own type size and color scheme.

SwifftUI Previews allow you to see compare different device contexts side by side, so you can achieve consistent layout and performance across the entire Apple universe (quick note: you can loop through multiple device names using ForEach).

This snippet renders your view on several devices at once — invaluable for ensuring design consistency across screen sizes.

#Preview("Device Previews") {
    ForEach(["iPhone SE (3rd generation)", "iPhone 15 Pro", "iPad Air (6th generation)"], id: \\.self) { device in
        ContentView()
            .previewDevice(PreviewDevice(rawValue: device))
            .previewDisplayName(device)
    }
}

How to preview multiple view instances

If your UI supports different data or visual states (e.g. loading, success, error), you can preview each state in isolation.

By visualizing multiple states simultaneously, you catch layout or state management issues early in development.

#Preview("Loading State") {
    DashboardView(state: .loading)
}

#Preview("Error State") {
    DashboardView(state: .error("Failed to load data"))
}

#Preview("Success State") {
    DashboardView(state: .success(mockData))
}

Some quick tips:

  • Use Group to preview multiple views at once.
  • Add names to your previews using the string argument.
  • Create a reusable preview wrapper if many of your views require a binding or model.
  • Combine all your variants into a single preview file.
  • Wrap preview-only helpers in #if DEBUG so they’re ignored in release builds:

Part 4: Common pitfalls and how to deal with them

Even though SwiftUI Previews are powerful, they’re not immune to glitches and it’s easy to make mistakes when you’re still getting used to them.

Here are common pitfalls and quick fixes for you to bear in mind.

ProblemFix / How-To
🐢 Slow or frozen previewsDisable Automatic Preview Updates for complex views. Refresh manually when needed (Option + click ▶ “Resume”).
⚠ Missing assets or environment objectsProvide mock data or inject @EnvironmentObject instances manually in your preview to prevent crashes.
🌐 Network calls running in previewsAvoid real network requests. Replace with stubs, fake responses, or static mock data.
💥 Build errors or preview crashesClear Derived Data (Shift + Command + K) and restart Xcode. Cached previews often cause build issues.
🧱 Preview-only code appearing in release buildsWrap preview-specific helpers or test data in #if DEBUG … #endif so they’re excluded from production.

You may well run into more snags as you go. If and when you do, feel free to reach out and we’ll do our best to help.

Conclusion

SwiftUI previews aren’t just a nice-to-have — they’re a fundamental part of a modern, efficient iOS development workflow. They empower developers to iterate faster, validate designs instantly, and maintain high-quality code through visual testing.

With Xcode 16, previews are smoother, faster, and more capable than ever. Whether you’re refining a small component or testing an entire app screen, SwiftUI previews help you work smarter and deliver polished results.

By mastering SwiftUI previews, you’re not just saving compile time — you’re building confidence in your code and ensuring that every pixel in your UI behaves exactly as expected.

Questions after reading all that? If so, please get in touch. We’re always here to help. Otherwise, happy coding!

]]>
iOS Push Notifications: Complete APNs & Swift Setup Guide https://bugfender.com/blog/ios-push-notifications/ Mon, 09 Mar 2026 10:00:00 +0000 https://bugfender.com/?p=11321 Continue reading "iOS Push Notifications: Complete APNs & Swift Setup Guide"]]> Whether you’re building your first iOS app or improving user engagement, understanding push notifications is essential. They’re how apps stay connected with users, delivering updates, reminders and alerts even when the app itself isn’t running.

In this guide, you’ll learn how to implement iOS push notifications from setup to debugging, using Swift and Apple Push Notification Service (APNs) in a real app.

We’ll cover APNs keys, permissions, payloads, and the tiny details that make notifications reliable across every iPhone and iPad.

By the end, you’ll know how to send, test, and debug iOS push notifications effectively, avoiding common APNs errors while improving both delivery performance and user retention.

What are iOS push notifications?

iOS push notifications are messages sent via access point names (APNs) from a server to an iPhone or iPad to inform the user about updates, alerts or actions, even when the app is closed.

In real apps, these notifications provide booking updates, delivery tracking, feature announcements, reminders, promotions, and re-engagement nudges. They play a vital role at various stages of the customer journey, and they rely on a few core components that route the message from our backend to the user’s device.

The main components are:

  • APNs: Apple’s delivery system that handles all notification routing for iOS devices.
  • Device token: A unique identifier that APNs assign to each device, so our server knows exactly where to send a notification.
  • App server: The backend service that prepares the JSON payload, signs the request and sends it to APNs for delivery.

These three pieces form the foundation of every iOS push notification system.

How iOS push notifications work

iOS push notifications register our app with APNs, sending a signed payload from our server and letting APNs deliver it to the device.

Once our app is set up and the user grants permission, each notification passes through a few steps before appearing on screen.

  1. Our app asks the user for permission to send them notifications.
  2. iOS registers the app with APNs. This returns a unique device token.
  3. The device token is sent to our backend server and stored for future sends. Device tokens can change, so we must update them regularly.
  4. When we trigger a notification, our server creates a JSON payload and signs it using our APN key.
  5. The server sends the payload to APNs, including the device token.
  6. APNs verifies the request and securely routes the notification to the correct device.
  7. iOS displays it as a banner, alert, lock-screen message or in-app notification, depending on user settings.

Local vs remote notifications

iOS supports two main types of notifications: local and remote. Both look identical to users, but they differ in how they’re created and delivered. Understanding the distinction helps us choose the right method for our app.

Local notificationsRemote notifications
Triggered on the user’s device without using a network.Sent from our backend server through the Apple Push Notification Service (APNs).
Used for scheduled reminders, in-app alerts or background tasks.Ideal for real-time updates, messages, or alerts from external data sources.
Content defined and managed within the app.Payload and delivery handled by our server and APNs.

In short: use local notifications for device-driven events, and remote notifications for dynamic, server-controlled updates.

Why push notifications will still matter in 2026

Push notifications were first rolled out in 2009 and they continue to drive high engagement, retention and repeat usage today. Even with stricter privacy rules, they remain one of the most effective ways to reach iOS users.

Some key performance signals to illustrate this point:

  • Open rates reach up to 20%, far above email.
  • iOS reaction rates stay around 4–5%.
  • CTRs often hit 25–30%.
  • Apps using push see nearly 3× higher 90-day retention.
  • Retention boosts: +120% (occasional), +440% (weekly), +820% (daily).
  • Opt-in rates remain steady at ~44%.
  • Nearly half of users say a push influenced a purchase.
  • 60% say pushes make them open an app more often.

Brands are still finding innovative ways to weave push notifications into their marketing mix. Bluesky, a rival to X, made headlines over the summer by adopting the activity notifications feature, which lets readers request notifications from specific accounts. The glowing coverage this roll-out has received demonstrates the enduring value and potential of push notifications.

How to set up push notifications in iOS

Setting up push notifications in iOS requires configuring our app to work with APNs. This includes generating the correct credentials, enabling the right capabilities in Xcode and confirming that everything works with a real-device test.

1. Create an APNs Auth Key (.p8)

To set up iOS push notifications, the first thing we need is an APNs Auth Key. This key allows our server to authenticate with APNs and send notifications to our app.

To generate the APNs key:

  1. Log in to the Apple Developer portal.
  2. Navigate to Certificates, Identifiers & Profiles → Keys.
  3. Create a new key and enable APNs for it.
  4. Download the .p8 file — note that we can only download it once.

We now have four values that our backend will need:

  • APNs .p8 file — the key that we just downloaded.
  • Key ID — visible next to our key in Certificates → Keys.
  • Team ID — found in the Membership Details of our developer account.
  • Bundle ID — from our Xcode project (Targets → General).

Keep these secure. Our server will need them to sign every request sent to APNs.

2. Enable push capabilities in Xcode

Once we’ve created our APN key, we can enable push support in our Xcode project. This is really useful, as it tells iOS that our app is allowed to receive remote notifications, and ensures that our provisioning profile includes the correct entitlements.

How to enable push notifications in Xcode:

  1. Open the project in Xcode.
  2. Select the app target → open Signing & Capabilities.
  3. Click + Capability and add Push Notifications.
  4. If we rely on background updates or silent pushes, we can also add Background Modes and enable Remote notifications.

Once this is enabled, Xcode updates our entitlements automatically. Our app is now set up to receive device tokens and handle incoming notifications sent through APNs.

3. Register and test notifications (with curl example)

With our APN key and Xcode capabilities configured, we now need to register our app for remote notifications and verify that everything works end to end.

First we request permission from the user through UNUserNotificationCenter, then call UIApplication.shared.registerForRemoteNotifications() to trigger APNs registration.

When iOS returns a device token in didRegisterForRemoteNotificationsWithDeviceToken, we send it to our backend so it can target this specific device during testing.

Note that APNs does not work on the iOS Simulator. You should always test on a physical device.

To confirm the full pipeline works, we can send a sample push notification using curl:

curl -v \\
--header "apns-topic: <OUR_BUNDLE_ID>" \\
--header "authorization: bearer <OUR_JWT>" \\
--data '{"aps":{"alert":"Test push!"}}' \\
<https://api.push.apple.com/3/device/><DEVICE_TOKEN>

If everything is set up correctly, we should see the test notification appear instantly on our device.

How to implement push notifications in Swift

Here’s where we move from theory to practice.

Once our app is configured to work with APNs we can focus on how it actually behaves in Swift. Here we decide when we ask for permission, how we handle the device token and what happens when a notification arrives in the foreground or background.

To recap, implementing push notifications in Swift means:

  • Requesting notification permission from the user.
  • Registering for remote notifications on app startup.
  • Handling the device token and possible errors.
  • Responding to notifications via UNUserNotificationCenter delegates.
  • Supporting advanced use cases like silent pushes and background updates.

In the following sections, we’ll walk through each part step by step, so our app can handle iOS push notifications reliably in real-world scenarios.

1. Register for remote notifications

To implement push notifications in Swift, we first register our app with the iOS notification system and APNs.

This step triggers APN registration and provides the device token our backend needs to send notifications. Note, though, that it requires permission from the user.

Typical flow in Swift:

UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
    if granted {
        DispatchQueue.main.async {
            UIApplication.shared.registerForRemoteNotifications()
        }
    }
}
  1. Set UNUserNotificationCenter.current().delegate = self early in app launch.
  2. Call requestAuthorization(options:) to ask for alert, sound, and badge permissions.
  3. If granted, call UIApplication.shared.registerForRemoteNotifications() on the main thread.
  4. Implement the device-token callback to receive and upload the token:
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02x", $0) }.joined()
    print("APNs token:", token)
    MyAPI.uploadDeviceToken(token)   // Send to backend
}
  1. Handle registration failures for easier debugging:
func application(_ application: UIApplication,
                 didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print("Failed to register:", error.localizedDescription)
}

This registration flow connects our Swift app to APNs and ensures we get a valid device token for testing and production pushes.

2. Handle foreground and background events

Notifications can arrive in both foreground or background states. With registration in place, we can now decide how our app will react when a notification arrives in either of these states.

Quick note: by default, iOS only shows banners and alerts when the app is in the background, so we often want more control on the Swift side.

We typically use UNUserNotificationCenterDelegate:

  • Foreground: Implement userNotificationCenter(_:willPresent:withCompletionHandler:) to decide whether to show an alert, play a sound, or update UI while the app is open.
  • Background / tapped: Implement userNotificationCenter(_:didReceive:withCompletionHandler:) to react when the user taps a notification—for example to navigate to a specific screen, open a chat, or load extra data.

By handling these callbacks, we can keep iOS behavior consistent across the different app states.

3. Support silent notifications and data refresh

Silent pushes are a powerful way to keep our app’s content fresh without interrupting the user. They let us refresh data or trigger background work without showing an alert to the user. This is useful for syncing content, updating badges, or preparing data before the user opens the app.

To support silent pushes in Swift, we:

  • Enable Background Modes → Remote notifications in Xcode (already done in setup).
  • Send pushes with {"aps": {"content-available": 1}} and no alert payload.
  • Implement application(_:didReceiveRemoteNotification:fetchCompletionHandler:) to handle the incoming payload in the background.
  • Perform lightweight work (e.g. fetch new data) and call the completion handler with .newData, .noData, or .failed.

APNs payload examples

Before continuing, let’s take a quick look at real APN payloads.

Every push notification is just a JSON object inside the aps dictionary, with optional custom fields for app-specific logic. Keeping payloads clean and lightweight improves delivery speed and avoids throttling, where we restrict the rate at which a function, request or process can run.

Here’s what the code strings look like:

Standard alert

{
  "aps": {
    "alert": {
      "title": "Hello!",
      "body": "Your order is on the way 🚚"
    },
    "badge": 1,
    "sound": "default"
  }
}

Silent/background push

{
  "aps": {
    "content-available": 1
  },
  "updateType": "sync",
  "timestamp": "2025-01-01T12:00:00Z"
}

Rich media (image/video)

{
  "aps": {
    "alert": "Big sale today!",
    "mutable-content": 1
  },
  "media-url": "<https://example.com/image.jpg>"
}

These templates cover the payload types used most often during development, testing and debugging.

Quick APNs sending tips (curl & endpoint basics)

A few quick checks make curl-based APN testing much easier. These small but essential steps help validate token correctness, endpoint selection and header formatting, the failure points that we will typically run into.

Essential curl tips

  • Use the right APN environment: Sandbox: https://api.development.push.apple.com/3/device/<TOKEN> Production: https://api.push.apple.com/3/device/<TOKEN>
  • Always include: apns-topic: <YOUR_BUNDLE_ID>
  • Keep test payloads small to rule out formatting issues.
  • Refresh JWTs frequently. APNs rejects expired or malformed headers.

These simple rules remove most of the guesswork when validating the full path from backend → APNs → device.

4. Add a minimal UNUserNotificationCenter implementation

UNUserNotificationCenter is the central object your app uses to schedule, deliver, manage, and respond to notifications. It lets us tie everything together and gives us a single place to manage permissions, callbacks and basic behavior – all with a minimal Swift implementation.

Here’s the code to show you:

import UserNotifications
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let center = UNUserNotificationCenter.current()
        center.delegate = self

        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
            if granted {
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
            }
        }

        return true
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler:
        @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        completionHandler([.banner, .sound])
    }
}

From here, we can extend the logic to handle background taps, silent pushes, and app-specific flows.

Managing iOS push notification permissions & user experience

It goes without saying that managing notifications is crucial to ensuring a smooth push experience. This is particularly true with iOS, which gives users full control over alerts.

It’s our job to request permission at the right time and explain why notifications matter. A poorly timed prompt can lead to permanent denial, while a thoughtful opt-in flow can dramatically improve our approval rates.

If we want to maintain good permission management, we need to remember 3 things:

  • Ask at the right moment.
  • Provide clear value.
  • Respect the user’s preferences.

In this section, we’ll walk through how to request authorization correctly, design a helpful pre-permission screen, and ensure that our push notifications feel useful and not intrusive.

When and how to request permission

When to Request PermissionHow to Request Permission
After the user performs an action that logically benefits from notifications (saving a favorite, enabling reminders, joining a community, etc.)Provide a short in-app explanation first (“We can notify you about…”) before triggering the system prompt.
When the user clearly understands the value of opting inTrigger the iOS permission alert only when there is context, not on first launch.
When the user is engaged, not rushing or exploring blindlyProvide a settings shortcut later if they decline. Don’t repeat prompts aggressively.
When we can track the user’s readiness or previous interactionsMonitor when the prompt was shown so we avoid asking at the wrong moment or too frequently.

Requesting permissions with proper timing and clear intent leads to significantly higher approval rates and a better overall user experience.

Designing a user-friendly opt-in flow

A user-friendly opt-in flow explains why notifications matter, and sets the right expectations before iOS displays its system prompt.

We can guide users with a simple pre-permission screen that highlights the value they’ll get—updates, reminders, alerts, or anything that improves their experience.

A good opt-in flow usually includes:

  • A short, friendly explanation of what we’ll send.
  • Clear benefits (“We’ll notify you when…”).
  • A single “Enable notifications” button that triggers the system alert.
  • A fallback option (“Maybe later”) that respects the user’s choice.
  • No walls or forced prompts.

When we combine clarity with respectful timing, users are far more likely to opt in—and stay opted in—because they understand the value of the notifications we send.

Localization & timing best practices

Localization Best PracticesTiming Best Practices
Localize notification text, action buttons, and categories so the entire flow feels natural.Send based on the user’s local time zone, not server time.
Match the tone to local expectations (some regions prefer direct language, others prefer more formal or friendly wording).Avoid disruptive hours: try to avoid sending notifications before 8 AM and after 10 PM.
Adapt content to local language and idioms to ensure clarity and relevance.Use high-engagement windows like 9–11 AM and 5–7 PM.
Reflect local holidays, events, and seasonal behavior in your messaging.Weekend and weekday engagement patterns vary—adjust schedules accordingly.
Support right-to-left languages and region-specific formatting.Avoid midday “rush hours” (e.g., 12–2 PM) for non-urgent pushes.

Example: For the Spanish market, a shopping reminder sent at 10:30 AM local time, localized to Spanish with a friendly tone (“Tus artículos siguen disponibles 😄” or “Your items are still available 😄) **will outperform a generic English notification sent at 6AM.

Debugging & troubleshooting iOS push notifications

Even when our APNs setup looks correct, it’s common for iOS push notifications to silently fail.

Most issues come from a small set of misconfigurations: wrong environment, invalid tokens, missing capabilities or authorization problems.

In this section, we’ll walk through the most frequent delivery issues, what to check first, and how to use logs and external tools to see what’s really happening between our server, APNs and the device.

The goal isn’t to debug every edge case, but to give us a fast checklist so we can move from “nothing shows up” to a working push in minutes instead of hours.

Fixing common delivery issues

SymptomWhat we should check
Notification never arrives on deviceConfirm the device is on a real device (not simulator) and has network access. Make sure the app is installed and not uninstalled after token generation.
Works on some devices but not othersCompare device tokens; old tokens may be invalid. Ensure we’re using the latest token from didRegisterForRemoteNotificationsWithDeviceToken.
No device token is generatedCheck that Push Notifications and Remote notifications (Background Modes) are enabled in Xcode, and registerForRemoteNotifications() is called after permission is granted.
APNs returns 400 / 403 / 410 errorsVerify our APNs Auth Key, Key ID, Team ID, and Bundle ID. Make sure we’re using the correct APNs endpoint (sandbox vs production) and topic.
Notification arrives but no alert is shownCheck the payload: does it include a valid aps.alert? On foreground, confirm willPresent delegate calls the completion handler with presentation options.
Notification shows but tap does nothingEnsure didReceive (or scene-based callbacks) handles navigation logic and that we aren’t swallowing the event or missing routing code.

Checking APNs keys, tokens & logs

When notifications don’t arrive, the first thing we validate is authentication and addressing: our APNs key, device tokens and logs.

A single mismatch in these values is enough to break delivery.

We start by confirming we’re using the correct .p8 APNs key, Key ID, Team ID, and Bundle ID, and that our server is sending the right apns-topic header.

Then we double-check that the device token we’re using is the latest one returned by iOS. Remember that tokens can change across installs, restores or environments.

On the logging side, we log every outgoing push request with:

  • the device token (masked in production logs)
  • the environment (sandbox vs production)
  • the APNs response status and body

We also check Xcode console logs on the device and our backend logs in parallel. This combination usually reveals whether the issue is in our app, our server, or APNs.

Debugging iOS push notifications with Bugfender

Bugfender makes troubleshooting iOS push notifications much easier by showing real-time logs from physical devices, no Xcode connection required. This helps us confirm whether the app is registering correctly, receiving callbacks, or failing silently.

With Bugfender, we can:

  • Confirm didRegisterForRemoteNotificationsWithDeviceToken is called and capture the token.
  • Detect errors from didFailToRegisterForRemoteNotificationsWithError.
  • Log incoming payloads in didReceiveRemoteNotification or notification center delegates.
  • Verify silent push callbacks and background delivery.
  • Compare logs across multiple devices to spot inconsistent behavior.

If APNs says the push was delivered but nothing appears on the device, Bugfender’s logs help pinpoint whether the problem is in permissions, app logic, or background handling.

iOS push notifications FAQ

What’s new for iOS push notifications in iOS 18?

iOS 18 introduces improved notification relevance ranking, smarter grouping, and expanded control over focus modes. Developers also get more consistent background delivery for silent pushes, plus refined permissions UX that makes timing and context even more important.

Can I send iOS push notifications without a server?

Yes, but only in limited cases. You can send test pushes using tools like curl, but real production pushes typically require a backend. Alternatives include Firebase Cloud Messaging, serverless functions, or third-party push providers that remove the need to run a full server.

What is the payload size limit for APNs?

APNs allows payloads up to 4 KB for remote notifications. For media attachments, APNs only delivers the URL and the app downloads the file. Keeping payloads small improves reliability and delivery speed.

Why are my push notifications not showing up on iOS?

Common causes include:

  • Missing permission (user tapped “Don’t Allow”)
  • Using the wrong APNs environment (sandbox vs production)
  • Invalid or outdated device token
  • Missing aps.alert in the payload
  • Foreground notifications not displayed due to delegate settings
  • Silent notifications arriving but not triggering UI updates

Checking tokens, APNs responses, and device logs (via Bugfender) usually identifies the issue.

Do iOS push notification device tokens change?

Yes. Tokens can change after reinstalling the app, restoring a device, switching environments, or when Apple rotates internal identifiers. Always send the latest token to your backend on every launch.

Can iOS deliver push notifications to the app when it’s killed?

Yes, remote notifications still arrive on the system and appear to the user. However, silent pushes won’t run background code if the user force-killed the app. Only visible alerts are guaranteed.

How often can I send iOS push notifications?

There’s no official APNs limit, but apps sending too many pushes may get lower engagement or higher opt-out rates. Data suggests 1–3 pushes per week is safe for most use cases unless the app is transactional (messages, delivery updates, reminders).

Do push notifications work on iOS simulators?

No. APNs does not deliver remote notifications to simulators. Testing must be done on a physical device.

Why do iOS push notifications get delayed?

Delays usually happen due to poor network conditions, large payloads, server-side retries, or the device being in low power mode. Silent pushes are especially affected and may be throttled by the system.

Conclusion: building reliable iOS push notifications

iOS push notifications aren’t just a “nice to have”, they’re one of the most effective ways to keep users engaged, informed, and returning to the app. Once we understand how APNs, device tokens, and payloads fit together, the setup becomes repeatable: configure APNs, enable capabilities in Xcode, register for remote notifications and handle events cleanly in UNUserNotificationCenter.

The real performance boost comes from smart permission timing, user-friendly messaging and proper debugging. When pushes fail silently, visibility into real devices is essential — which is exactly where Bugfender helps uncover what’s actually happening in production.

Get these fundamentals right once, and we can ship iOS push notifications in Swift that are reliable, respectful, and consistently great for retention.

]]>
JavaScript Exception Handling: try, catch, throw, async & Best Practices https://bugfender.com/blog/javascript-exception-handling/ Wed, 04 Mar 2026 10:00:00 +0000 https://bugfender.com/?p=7592 Continue reading "JavaScript Exception Handling: try, catch, throw, async & Best Practices"]]> Exceptions are inevitable. It’s how we deal with them that matters. An effective exception handling regime is the difference between an app that only works in sandbox and one that can adapt and scale in the real world.

JavaScript can throw up all kinds of weird and wonderful exceptions, because it runs in inherently unpredictable environments. So we’ve put together this guide to give you a clear, repeatable plan for handling them.

If you’re debugging a specific issue, you can jump to:

But if you prefer a complete understanding of how JavaScript handles exceptions from start to finish, follow the sections in order.

First, what is a JavaScript exception?

A JavaScript exception is a runtime error that interrupts normal program execution.

It typically occurs when:

  • A variable is undefined.
  • A function is called incorrectly.
  • Invalid input is passed.
  • An operation fails unexpectedly.

When an exception happens:

  • The current block of code stops executing.
  • JavaScript searches for an error handler.
  • If no handler exists, it becomes an uncaught exception.

What is JavaScript exception handling?

Exception handling in JavaScript is the process of detecting, managing, and recovering from runtime errors using try, catch, throw, and finally, including asynchronous error handling patterns.

Instead of letting errors terminate execution unpredictably, we can:

  • Isolate risky code.
  • Define recovery logic.
  • Prevent crashes and broken user flows.
  • Clean up resources safely.

Structured error handling turns crashes into controlled outcomes.

try, catch, and finally in JavaScript

try...catch...finally is JavaScript’s built-in exception handling statement. Think of this as the 1-2-3 of your exception handling regime: isolate the risky code, define the response, run the clean up.

try {
  // code that may throw
} catch (error) {
  // handle the error
} finally {
  // always runs
}

The try block

try wraps code that might throw an exception.

  • If no error occurs: all statements inside try execute, and catch is ignored.
  • If an error is thrown: execution inside try stops at that line, and control transfers directly to catch.
try {
  const json = JSON.parse('{"ok": true}');
  console.log(json.ok);
}

The catch block

catch runs only when an exception is thrown inside try.

When this happens, JavaScript passes an Error object to catch, allowing us to inspect what went wrong and decide how to respond.

This object contains useful diagnostic information, especially:

  • error.message → our concise description of the failure.
  • error.stack → the execution trace showing where it originated.
try {
  JSON.parse("{ broken json }");
} catch (error) {
  console.error("Parsing failed:", error.message);
}

The common actions you can build into catch include:

  • Logging the error with additional context.
  • Displaying a fallback UI or message.
  • Returning a safe default.
  • Rethrowing the error if it should be handled higher up.

error.message

This isn’t just another notification: the clarity of the error.message directly affects how quickly we can diagnose the issue. So it needs to clearly explain what went wrong and include enough context to make the issue obvious without needing to reproduce it.

At Bugfender we’ve actually created a set of best-practice guidelines to help our team write strong error.message values. Here it is:

  1. Describe the exact expectation that was violated.
  2. Include relevant identifiers or values.
  3. Avoid generic phrases like “Something went wrong”.
  4. Keep the message concise but specific.
  5. Make it easy to search in logs.

Want some examples? Sure thing. Here are some examples of weak vs. effective messages:

Weak / Vague MessageClear / Actionable Message
"Invalid input""Expected 'email' to be a non-empty string"
"Type error""Expected a number for 'age', received undefined"
"Not found""User not found: id=4821"
"Request failed""GET /api/orders returned 404 for orderId=983"

error.stack

error.stack is crucial to isolating and identifying the problem, showing us exactly where (and how) the failure occurred.

The stack trace is generated automatically by the JavaScript engine and lists the sequence of function calls involved, along with file names and line numbers. Here’s an example structure:

Stack LineWhat It Indicates
TypeError: Cannot read properties of undefinedError type and message
at getUser (user.js:42:15)Exact file and line of the failure
at handleRequest (api.js:18:5)The function that triggered it

In many cases, the top frame shows where the error surfaced – not where it was introduced. The root cause is often a few calls below, especially when invalid data travels through multiple layers.

The finally block

finally executes after try (and catch, if one runs), regardless of the outcome.

It’s the place for logic that must execute unconditionally, and its purpose is deterministic cleanup – not handling errors or making decisions.

Common examples:

  • Stopping a loader.
  • Closing files/connections.
  • Resetting temporary state.
setLoading(true);

try {
  await doWork();
} catch (error) {
  console.error(error);
} finally {
  setLoading(false);
}

JavaScript Error object

As seen in catch, JavaScript represents most runtime failures using the built-in Error object. This provides a structured way to describe and trace problems consistently across the runtime.

An Error instance includes:

  • name → the error type, set automatically by the constructor.
  • message → the human-readable explanation we provide.
  • stack → the call stack generated at the moment the error occurred.

The base Error constructor is appropriate for general-purpose failures when no more specific subtype applies.

const err = new Error("Unexpected condition");

console.log(err.name);    // "Error"
console.log(err.message); // "Unexpected condition"
console.log(err.stack);   // stack trace

Common error types and how to fix them

JavaScript includes several built-in error types that describe specific categories of runtime problems. By isolating the error type, we can get to the root of the problem more quickly and narrow down the potential fixes.

Here are some typical errors and fixes for you to take on board:

Error Type & Typical ScenarioHow to Fix It
TypeError — Calling a method on undefined or using the wrong data typeValidate inputs before use and ensure values are defined and of the expected type.
ReferenceError — Accessing a variable that hasn’t been declaredCheck for typos and ensure the variable is declared and in scope.
RangeError — Providing a value outside permitted boundsValidate numeric limits and boundary conditions before assignment.
SyntaxError — Invalid JavaScript syntaxCorrect the syntax issue (missing brackets, invalid tokens).
URIError — Malformed URI passed to encoding/decoding functionsSanitize and validate URI components before processing them.

Understanding these error categories helps us react appropriately when something fails.

But instead of waiting for built-in errors to occur, we can also enforce our own rules and signal failures deliberately — which is where throw becomes useful.

How to throw exceptions in JavaScript

JavaScript raises errors automatically when something unexpected happens. But we can also signal failures intentionally.

The throw statement allows us to stop execution at a specific point when a required condition isn’t met.

When we call throw:

  • Execution stops immediately
  • The current function exits
  • Control moves to the nearest matching catch block

For example, if a function expects a numeric age, we can enforce that assumption explicitly:

function validateAge(age) {
  if (typeof age !== "number") {
    throw new TypeError("Expected 'age' to be a number");
  }

  return age;
}

This prevents invalid data from continuing deeper into the system, where the failure would be harder to trace.

Throwing errors for input validation

Input validation is one of the most common reasons to use throw.

When data violates an assumption — such as a missing field, invalid format, or out-of-range value — it’s usually better to fail fast with a clear error than to let incorrect data propagate through the system.

Below are common validation scenarios and appropriate error types:

Validation ScenarioExample Error to Throw
Required field is missingthrow new Error("Email is required")
Incorrect data typethrow new TypeError("Email must be a string")
Invalid formatthrow new TypeError("Email must contain '@'")
Value outside allowed rangethrow new RangeError("Password must be at least 8 characters long")

Throwing custom errors

A custom error is a user-defined class that extends JavaScript’s built-in Error object to represent application-specific failures.

While built-in error types describe technical problems, custom errors allow us to label domain or business rule violations explicitly. This makes logs easier to interpret and enables more precise error handling strategies.

Common use cases:

ScenarioWhy Use a Custom Error
Business rule violation (e.g., user already exists)Distinguishes domain logic from technical failures
Authorization failureSeparates permission issues from input or type errors
Payment or subscription state invalidEnables higher-level handling (retry, notify user, etc.)
Application-specific validationMakes logs searchable by domain category

Example: Defining and handling a custom business error

In this example, we model a business rule — “you cannot withdraw more than your balance” — as a custom error. This allows higher-level code to distinguish expected domain failures from unexpected system issues.

class InsufficientBalanceError extends Error {
  constructor(message) {
    super(message); // Initialize built-in Error (message + stack)
    this.name = "InsufficientBalanceError";
  }
}

function withdraw(account, amount) {
  if (amount > account.balance) {
    throw new InsufficientBalanceError(
      `Cannot withdraw ${amount}, available balance is ${account.balance}`
    );
  }

  account.balance -= amount;
  return account.balance;
}

try {
  withdraw({ balance: 100 }, 200);
} catch (error) {
  if (error instanceof InsufficientBalanceError) {
    console.error("Business rule violated:", error.message);
  } else {
    console.error("Unexpected system error:", error);
  }
}

Here, the custom error gives meaning to the failure, making it easier to handle intentionally rather than treating every problem as a generic exception.

Handling asynchronous exceptions in JavaScript

A try...catch block only catches errors that occur synchronously inside the try block.

Modern JavaScript handles most asynchronous operations — such as network requests, timers, or database calls — using Promises.

A Promise is a built-in JavaScript object that represents the future result of an asynchronous operation. Instead of returning a value immediately, it:

  • resolves (success) → delivers the resulting value to the next .then() handler
  • rejects (failure) → delivers an error reason, without throwing synchronously; the error is stored inside the Promise and must be handled using Promise-specific patterns

Because Promise rejections do not behave like synchronous throws, we need specific patterns to handle asynchronous errors.

In practice, there are two correct approaches to handling Promise rejections:

  • Promise chaining with .catch()
  • async/await wrapped in try...catch

Using .catch() with Promises

When working with Promises directly, attach .catch() to handle rejections.

fetch("/api/data")                 // Start an asynchronous HTTP request
  .then((res) => res.json())       // Parse the response body as JSON
  .then((data) => {
    console.log(data);             // Handle successful result
  })
  .catch((error) => {
    console.error("Request failed:", error.message); // Handle rejected Promise
  });

If .catch() is missing, a rejected Promise may become an unhandled promise rejection.

In browsers, this triggers console warnings; in Node.js, it may terminate the process depending on runtime configuration.

Important: fetch() only rejects on network-level failures (no connection, DNS issues, request blocked). An HTTP response like 404 or 500 still resolves the Promise — so it won’t land in .catch().

If you also want 4xx/5xx responses to be treated as failures, you must check response.ok and throw manually (this is a common production pattern):

fetch("/api/data")                           // Start request
  .then((response) => {
    if (!response.ok) {                      // HTTP-level failure (4xx/5xx)
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();                  // Parse JSON only if OK
  })
  .then((data) => {
    console.log(data);                       // Handle successful data
  })
  .catch((error) => {
    console.error("Request failed:", error.message); // Catch network or thrown errors
  });

In larger codebases, this check is often wrapped in a shared helper (or handled by a client library like Axios) so every request consistently treats HTTP errors the same way.

try...catch with async/await

With async/await, try...catch behaves like synchronous error handling – as long as we await the Promise inside the try block.

async function loadData() {
  try {
    const response = await fetch("/api/data");   // Wait for the Promise to settle

    if (!response.ok) {                          // Handle HTTP 4xx/5xx manually
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();          // Await parsing
    return data;                                 // Success path
  } catch (error) {
    console.error("Load failed:", error.message); // Handles rejection or thrown errors
    return null;
  }
}

Key rule: If we do not use await, the Promise is not paused inside the try, and its rejection will not be caught there.

For example, this will not be caught:

try {
  fetch("/api/data"); // Missing await
} catch (error) {
  // This will not run
}

Because without await, the function does not wait for the Promise to reject.

Common async error handling mistakes

Apart from the two mistakes covered above, asynchronous flows introduce their own subtle pitfalls.

Scenario (and why it’s a problem)What to Do Instead
Mixing .then() and async/await in the same function, which makes control flow harder to follow and error propagation inconsistentChoose one style per function. Prefer async/await for readability and centralized try...catch.
Forgetting to return a Promise inside a .then() chain, which breaks the chain and prevents outer .catch() from runningAlways return the Promise from each .then() step so errors propagate correctly.
Catching errors too early in a lower-level function, which hides failures from higher-level logicLet errors bubble up unless that layer can meaningfully recover. Handle them where decisions are made.
Treating every rejection as recoverable, which can allow invalid state to continue silentlyDecide intentionally: retry, fallback, escalate, or rethrow. Not every error should be suppressed.

As your app scales, a strong async strategy will keep propagation predictable and reduce the time you waste when analysing your errors. Fewer blind alleys, more pinpoint solutions.

Uncaught exceptions and production behavior

An uncaught exception is an error that escapes local handling.

When nothing catches it, JavaScript has no safe recovery path, so the runtime falls back to global behavior.

The exact behavior differs between browsers and Node.js, but in both cases it signals that the application is no longer in a reliable state.

Global error handling in browser

Something that we’ve learned the hard way at Bugfender: in the browser, an uncaught exception affects only the current page, but it can still disrupt user experience. When an exception is not handled locally, it typically:

  • Stops the current execution path.
  • Appears in DevTools.
  • Can break UI interactions or leave components in an inconsistent state.

Browsers also report unhandled Promise rejections when a rejected Promise has no error handler attached. These indicate asynchronous failures that were never properly handled.

Global listeners can be registered for logging and monitoring:

window.onerror = function (message, source, lineno, colno, error) {
  console.error("Global error:", message);
};

window.onunhandledrejection = function (event) {
  console.error("Unhandled rejection:", event.reason);
};

These handlers are useful for reporting errors, but significantly, they do not restore application state.

Global error handling in Node.js

Node.js is great for extending the possibilities of JavaScript, but an uncaught exception is particularly savage in this environment. When an error is not handled locally, it can terminate the process, leave open connections or incomplete operations and corrupt in-memory state if it’s allowed to fester.

How do we stop this? Well like the browsers we covered earlier, Node.js reports unhandled Promise rejections when a rejected Promise has no handler attached. Global listeners can be registered like this:

process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
});

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
});

In production systems, uncaught exceptions are often treated as fatal. Instead of attempting recovery, the process is logged and restarted cleanly using a process manager.

Logging and monitoring uncaught errors

When an error reaches the global level, the only reliable response is visibility.

  • Without production logging → you see a crash, but lack the stack trace and runtime context needed to diagnose it.
  • With production logging → you capture the error message, stack trace, and real environment data, turning failures into actionable fixes.

If you’ll forgive the slightly corny line, production logging makes uncaught exceptions traceable instead of mysterious.

Tools like Bugfender let you capture production logs directly from real user devices, giving you visibility into errors that never surface in local testing.

You can try Bugfender for free and start monitoring real-world failures as they happen.

An example of production error monitoring in Bugfender.

An example of production error monitoring in Bugfender.

A quick JavaScript exception handling checklist

Again, I want to share a checklist we’ve put together at Bugfender. Ideally, you should be able to check all of these before you ship:

  • [ ] We throw Error objects (not strings or numbers)
  • [ ] We use specific error types when applicable
  • [ ] Business rule violations use custom errors
  • [ ] We do not swallow errors silently
  • [ ] If we catch and rethrow, we preserve the original error
  • [ ] Every Promise chain ends with .catch()
  • [ ] Every async block awaits Promises inside try
  • [ ] We do not mix .then() and async/await in the same flow
  • [ ] Uncaught exceptions are logged globally
  • [ ] Unhandled Promise rejections are monitored in production

If multiple items can’t be checked confidently, your exception handling strategy likely has gaps.

FAQs about JavaScript exception handling

Can I rethrow an exception after catching it?

Yes. You can catch an error, add context, and rethrow it:

try {
  riskyOperation();
} catch (error) {
  console.error("Database failed:", error);
  throw error; // rethrow
}

Rethrowing is useful when you want higher layers to decide how to handle the failure.

What is an unhandled Promise rejection?

An unhandled Promise rejection occurs when a Promise fails and no .catch() handler is attached.

In browsers, it appears as an “Unhandled Promise Rejection” warning.

In Node.js, it may terminate the process depending on runtime settings.

Always attach .catch() or use await inside try...catch.

Should I catch errors at every level?

Short answer: No. Catching errors too early can actually be counter-productive as it will hide useful debugging information.

Instead:

  • Throw errors where they happen.
  • Catch them at architectural boundaries (API layer, UI layer, job runner, etc).
  • Handle errors where meaningful recovery or logging can occur.

Does finally override return statements?

Yes, it can.

If finally returns a value, it overrides returns from try or catch.

This behavior can be surprising and should be used carefully.

Can I create my own error types?

Yes, by extending the Error class:

class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseError";
  }
}

Custom error classes improve clarity and allow more precise error filtering.

Do all JavaScript APIs throw exceptions?

No. Some APIs (like fetch) resolve successfully even for HTTP errors (e.g., 404 or 500).

In those cases, you must check response properties like res.ok manually.

Not all failures automatically throw exceptions.

Final Thoughts

JavaScript exception handling is not about catching every error. It is about designing predictable failure paths.

When errors are thrown intentionally, handled at the right layer, and monitored in production, they stop being crashes and become signals.

So treat failures not as a breakdown in your architecture, but as part of your architecture, but as a key part of it. When things do break, you’ll know exactly where to look.

Happy debugging!

]]>
JavaScript Debugging: How to Find and Fix Bugs in JS https://bugfender.com/blog/javascript-debugging/ Mon, 02 Mar 2026 07:53:56 +0000 https://bugfender.com/?p=7726 Continue reading "JavaScript Debugging: How to Find and Fix Bugs in JS"]]> An effective JavaScript debugging regime is essential if we want to build responsive, reliable and highly-rateable Android apps.

JavaScript doesn’t enforce types at compile time (unlike Swift) and this means errors often happen quietly, when users are already feeling them. So it’s vital that we debug pre-emptively, using knowledge rather than guesswork.

Debugging is the reason Bugfender exists, and we’ve created this post to give you a full armoury of tips and techniques, so you can debug JavaScript efficiently at scale.

  • We’ll give you a step-by-step guide to debugging.
  • Then we’ll provide some deep-dives into the various tools at your disposal.
  • Finally, we’ll give you some tips to take into your day-to-day work.

Here’s the full list of contents, so you can jump straight to the information you need – or carry on reading if you want the entire knowledge bank.

💡

Quick caveat: this guide focuses on debugging runtime behavior and logic issues, not exception-handling patterns like try-catch. Want to read about that? Try this instead.

The four pillars of JavaScript debugging

Debugging works best when we follow a clear process instead of jumping randomly between logs, breakpoints and fixes. In fact most issues can be solved quickly using this four-step process:

  1. Recognize the bug signals by observing errors, wrong behavior, timing issues or production-only failures.
  2. Choose and use the corresponding debugging workflow based on that signal, before you dip into the tools.
  3. Verify the JavaScript fix by reproducing the original scenario and confirming that the behavior is now correct.
  4. Prevent the bug from coming back by adding guards, defaults, logging or basic coverage.

This approach keeps debugging predictable, efficient, and easier to repeat across projects.

Step 1: Recognize the JavaScript bug signals

To recognize the type of JavaScript bug we’re dealing with, we must initially focus on the signal, not the code.

Before we open DevTools, we can observe what the system actually tells us via these methods:

  • Look for explicit errors: check the console for TypeError, ReferenceError, SyntaxError or a stack trace pointing to a file and line.
    • Open DevTools via right-click → Inspect, or Cmd + Option + I on macOS, Ctrl + Shift + I on Windows/Linux).
  • Observe the behavior: does the code run but produce the wrong output, undefined values, or incorrect UI with no error?
  • Watch for timing clues: does the issue appear only sometimes, during loading, or depending on execution order or network speed?
  • Check the environment: does it work locally but fail in production, on certain browsers, or specific devices?

These signals are usually enough to identify the bug type and move to the correct debugging workflow, rather than just guessing.

The most common JavaScript bugs that developers run into

What breaks in JavaScriptTell-tale signs, typical causes, and workflow
Runtime errorsJavaScript throws a visible TypeError, ReferenceError or SyntaxError, usually with a stack trace.
Undefined valuesA variable or property is undefined
, no error is thrown, but the UI or result is wrong. Common causes include missing data, incorrect object paths, or code running before initialization → Behavior-based debugging
Async timing issuesFunctions run without errors but return incorrect or missing values in some code paths. Often caused by off-by-one errors, conditional logic gaps, or unexpected type coercion → Behavior-based debugging
Async timing issuesLoading never ends, works only sometimes, or its behavior changes based on order or timing. Usually caused by race conditions, unresolved promises, or incorrect async/await
usage → Async and network debugging
Network-related bugsRequests fail silently, return unexpected data, or arrive too late. Common causes include API response changes, failed requests, retries or timeouts → Async and network debugging
Environment-specific bugsWorks locally but breaks in production, or only on specific browsers or devices. Often caused by environmental differences, missing configuration, or browser quirks → Production debugging

Note: Many bugs don’t throw errors at all. For a breakdown of silent, non-crashing issues (like incorrect UI state, missing updates or logic bugs), see our guide to 10 common non-crashing JavaScript bugs and how to fix them.

💡

Humble flex: With Bugfender, you can use the dashboard to zoom in on an affected device or user; review recorded console logs, errors, UI events and network calls; see the exact sequence that led to a bug without guessing, and replicate on a purely local basis to test out your fix.

Step 2: Use the correct JavaScript debugging workflow

Once we’ve recognized the bug type, we can choose the specific debugging workflow for that signal.

Each workflow is designed for a specific situation, so this approach allows us to keep debugging focused and avoid random trial and error.

The sections below give you the exact steps for each case.

Error-based JavaScript debugging

Error-based debugging is the best place to start, simply because errors are visible and unavoidable. Here’s how you can tackle the process.

  1. Read the error message carefully. Identify the error type (TypeError, ReferenceError, SyntaxError) and note what JavaScript is complaining about.
  2. Follow the stack trace. Jump to the first stack trace entry that points to your own code, not framework or library files.
  3. Locate the failing line. Open the exact file and line number where the error occurs.
  4. Identify the broken assumption. Check which value, variable, or function caused an error message (like undefined, wrong type or missing import).
  5. Confirm the inputs. Add one or two targeted logs or use a breakpoint to inspect values right before the error happens.
  6. Correct the failing assumption. Update the specific variable, import or call that caused the runtime error, without refactoring unrelated logic.

Some common JavaScript error types you should look out for

JavaScript error typeHow to recognize it
TypeErrorA value exists, but is used incorrectly (for example, calling something that isn’t a function or accessing a property on undefined).
ReferenceErrorA variable or function is used before it exists or is not defined in the current scope.
SyntaxErrorJavaScript code is invalid and fails to parse, often due to missing brackets, commas, or incorrect syntax.
RangeErrorA value is outside an allowed range, such as invalid array length or recursion exceeding limits.

Using the browser DevTools debugger (breakpoints, step over, watch, call stack)

The browser debugger is ideal when we need to pause JavaScript execution and inspect what’s really happening, instead of using our intuition with logs. It’s especially useful for error-based and behavior-based debugging, as it enables us to:

  • Set breakpoints on suspicious lines to stop execution at the exact moment logic runs.
  • Use the Call Stack to see how execution reached that line and which function triggered it.
  • Inspect variables and scope to verify values, closures and assumptions at runtime.
  • Step over, into and out of functions to follow execution flow line by line.
  • Add watch expressions to monitor how key values change as code runs.

This tool is particularly useful when our logic looks correct but produces unexpected results.

For more, see Google Chrome Developer Tools.

Behavior-based JavaScript debugging

Behavior-based debugging is slightly more challenging than user-based debugging, at least initially. Our training as developers is typically code-centric, and switching to behavior can feel slow and clunky, even when it’s actually quicker.

But like any facet of programming, behavior-based programming is easy to master if we keep things simple. Here’s the key stuff to remember.

  1. Reproduce the wrong behavior consistently. Trigger the feature or interaction until the incorrect result appears reliably. If possible, identify a similar case that works correctly.
  2. Define the mismatch clearly. State what you expect to happen versus what actually happens. This keeps the focus on outcomes, not assumptions.
  3. Inspect values at the boundary. Log or inspect the data where it enters or leaves a component, function, or UI render. This shows whether the issue is input, processing or output.
  4. Trace the data flow backward. Follow the value from the wrong result back through handlers, functions or state updates to find where it first becomes incorrect.
  5. Check logic paths and returns. Review conditionals, early returns, default cases and state updates to ensure every path produces a valid result.
  6. Correct the logic mismatch. Adjust the condition, return path or state update that produces the wrong behavior, keeping the change as small as possible (this bit’s super-important).

JavaScript console logging (quick inspection & data tracing)

Console logging is most effective when JavaScript runs without errors but produces the wrong result. It’s a key part of behavior-based debugging as it helps us trace how data flows through the app and where values start to diverge from expectations, so we can:

  • Log inputs and outputs at component, function, or UI boundaries to validate data entering and leaving.
  • Log intermediate values to detect where a value changes unexpectedly.
  • Use console.table() for arrays and objects to spot incorrect structures or missing fields.
  • Log inside conditionals and before returns to confirm which logic paths execute.
  • Remove or replace logs once the incorrect behavior is identified.

For a complete guide, see the full breakdown: JavaScript Console Log: How to Debug Effectively

Async and network JavaScript debugging

Now we’re getting deeper into the weeds.

Rather than focusing on straight-line logic and state, we’re diving into state changes over time, looking deeply at timing and external systems. This can seem a bit daunting, but the following snippets have all proved useful to us at Bugfender.

  1. Reproduce the issue under controlled conditions. Trigger the feature multiple times and, if possible, slow things down using network throttling to make timing issues easier to observe.
  2. Confirm the async path is reached. Add logs or breakpoints before and after await calls, promise chains, or callbacks to ensure the code actually executes in the expected order.
  3. Inspect promise and return chains. Check that every async function returns a value and that promises are properly awaited, handled or chained. Broken chains are a common cause of silent failures.
  4. Examine network requests. Use the Network panel to verify request URLs, status codes, response payloads and timing. Don’t assume a successful status means valid data.
  5. Check loading and error states. Ensure the UI updates correctly for success, failure and pending states, and that one state doesn’t block another.
  6. Stabilize the async flow. Fix the broken await, promise chain or state transition that causes timing or loading issues, without restructuring the entire flow.

Network panel and async debugging tools (requests, timing, throttling)

Async and network bugs happen when JavaScript runs, but not in the order or timing we expect.

Why does this happen? Well JavaScript can only do one thing at a time, so it’s liable to delegate work and come back to it later, which can really throw our own work out of sync.

We’ve run into this problem a lot over the years, and here are some of the tips and hacks we’ve used to spot delays, missing awaits and broken data flows.

  • Use network throttling to slow requests and expose race conditions.
  • Inspect request URLs, status codes and payloads to verify data shape and timing.
  • Check request order to spot out-of-sequence responses.
  • Confirm async code paths with breakpoints or logs before and after await.
  • Watch loading, success and error states to ensure none block each other.

As you progress with async and network debugging, you’ll find that all these lessons scale with you.

Production and device-specific JavaScript debugging

Even when we’ve mastered the nuances of JavaScript, we need to think about the edge cases: the stuff that works fine in our local dev environment but will throw a switch once we’ve hit production or shipped to the public. The real world is inherently messy, and device-specific issues are extremely common.

If you run into a production or device-specific error, these checks should help you out.

  1. Confirm the issue doesn’t reproduce locally. Verify that the same steps work in development but fail in production or on specific browsers or devices.
  2. Capture environment details. Record browser, OS, device type, app version, feature flags, and any configuration that might differ from local development.
  3. Collect runtime signals from real users. Gather available logs, error reports, and network failures instead of relying on local console output.
  4. Identify production-only differences. Check for minified builds, missing source maps, environment variables or code paths that only run in production.
  5. Reproduce under real conditions if possible. Test on the same browser, device or environment, or simulate those conditions as closely as possible.
  6. Patch the environment-specific issue. Apply a targeted change that fixes the production or device-specific failure without masking other errors or altering unrelated behavior.

Runtime logging and remote debugging (production visibility)

When bugs only appear in production or on specific devices, local debugging tools stop working. At this point, runtime visibility is our sole remaining backstop.

  • Capture logs, errors, and breadcrumbs from real users instead of guessing.
  • Record environment details like browser, OS, device and app version.
  • Track network failures and edge cases that never reproduce locally.
  • Use source maps to connect production errors back to original code.
  • Monitor behavior after releasing a fix to confirm the issue is gone.

Another quick (and hopefully not too showy) brag: Tools like Bugfender make this possible by collecting logs directly from real sessions, without relying on user reports.

Start capturing production logs for free: https://dashboard.bugfender.com/signup

Step 3: Verify the JavaScript fix

That last section was pretty long, right?! Well, this one will be much shorter.

After applying the fix, we verify that the original bug signal is gone and the app behaves correctly in the same conditions as before. Five key steps to remember:

  1. Repeat the exact steps that previously triggered the bug.
  2. Confirm the original signal disappears (no error, correct UI, no infinite loading).
  3. Refresh or restart and repeat once to rule out cached or stale state.
  4. Test 1–2 nearby cases that use the same logic (similar inputs, same feature path).
  5. Check the console and network for new warnings or unexpected failures.

If any step fails, go back to Step 2 and continue from the last confirmed point.

Step 4: Prevent the bug from coming back

After confirming the fix works, we can reduce the chance of the same bug returning in future changes. Here are some tips to help with that.

  1. Add a guard or fallback (null checks, defaults, early returns) where the bug originated.
  2. Log the risky state so similar issues are visible if they happen again.
  3. Cover the case with a test (unit, integration, or even a manual checklist if automated tests aren’t available).
  4. Document the assumption in code with a short comment explaining why it exists.
  5. Scan for duplicates by searching the codebase for the same pattern or logic.

This turns a one-time fix into a durable improvement.

Top 10 JavaScript debugging tools

Now we’ve gone through the steps, let’s start looking at the tools we use to handle them.

There are all kinds of solutions and technologies we can use to make our lives easier, and each has a distinctive use case.

ToolWhat to use it for
Browser DevTools (Chrome, Firefox, Safari)Inspect errors, step through code with breakpoints, analyze runtime behavior and debug directly in the browser.
JavaScript ConsoleLog values, inspect objects, print tables and trace execution paths during runtime.
BreakpointsPause execution at specific lines, conditions, or events to inspect state at the exact moment a bug occurs.
Conditional breakpointsStop execution only when a condition is met, avoiding noisy pauses during normal flows.
LogpointsLog values without modifying code or adding temporary console.log statements.
Source mapsDebug original source code even when working with bundled or minified production builds.
Network inspectorVerify requests, responses, payloads, headers and timing to both diagnose API and async issues.
debugger statementForce execution to pause at runtime in hard-to-reach or asynchronous code paths.
Error stack tracesIdentify where an error originated and how execution reached that point.
Runtime logging & monitoringCapture logs and errors from real users, devices and production environments.

Using AI to debug JavaScript faster

We’ve got to mention AI here, right? We all know that AI has become a fundamental part of the developer’s armoury: apparently 80% of us use AI in our dev shops, or at least plan to.

Does AI offer specific benefits for debugging? Well it can certainly scan code, logs and stack traces much faster than a human can. However, it’s important to remember that AI tools can’t replace debugging workflows. They can simply accelerate specific steps when used correctly.

Here are some tips to weave AI into your debugging strategy.

  1. Prepare a minimal, focused input. Isolate the smallest code snippet, error message or behavior that reproduces the bug. Don’t just paste the whole codebase. That’s sloppy and counter-productive.
  2. Describe the signal, not just the code. Explain what happens, what you expect, and the conditions that lead to failure (error, wrong output, async timing, production-only).
  3. Share concrete artifacts. Include stack traces, console output, network payloads, screenshots, or short screen recordings when available.
  4. Ask for diagnosis, not blind fixes. Prompt AI to explain why the bug happens and which assumption is broken.
  5. Validate suggestions locally. Apply changes manually and verify using the same workflow and verification steps.
  6. Use AI iteratively. Refine prompts with new findings instead of asking for a one-shot solution.

Used this way, AI reduces investigation time while allowing you to channel your own skill and judgement.

Some useful AI prompt templates for JavaScript debugging

Feel free to copy-paste any of these prompts we’ve developed for JS projects.

Debugging situationCopy-paste prompt
Runtime errors (TypeError, ReferenceError, SyntaxError)“Act as a senior JavaScript developer. I’m getting a runtime error. Explain what assumption is breaking, why it happens, and the smallest fix. I’ll paste the details below.”
Wrong behavior, no errors“Act as a debugging partner, not a code generator. This code runs without errors, but the behavior is wrong. Explain where logic diverges and how to verify it.”
Async / timing issues“Help me debug async JavaScript behavior. Something works sometimes or never finishes loading. Analyze the async flow and tell me what to inspect next.”
Network-related bugs“Help me debug a JavaScript bug involving network requests. Focus on request timing, payload shape, and state handling.”
Production-only bugs“Act as a production debugging assistant. This bug doesn’t reproduce locally. Suggest likely causes and what data or logs I should capture next.”
Understanding before fixing“Before suggesting any fix, explain the root cause in plain English and identify the broken assumption.”
Verification & regression prevention“I applied a fix. Tell me how to verify it properly and what small guardrail could prevent regression.”

Best practices for debugging JavaScript

We’ve gone through the individual components of JS debugging. Now, let’s move onto the panoramic stuff – the tips that will knit your entire strategy together.

Remember: Effective JavaScript debugging comes from consistent best practice, not one-off tricks. These practices should scale with your operation and deliver results across a range of scenarios.

  • Limit the scope early. Narrow the issue to one function, state change, or async boundary.
  • Start from the signal. Decide whether you’re dealing with an error, wrong behavior, async timing, network, or production-only bug.
  • Check high-probability code first. Look at recent changes, complex logic and async flows.
  • Observe real values. Use breakpoints, watches, console.table(), and network inspection instead of just guessing.
  • Reduce execution noise. Pretty-print minified code and black-box vendor scripts.
  • Change one thing at a time. Be sure to verify each fix before moving on.
  • Watch for side effects. Confirm that unrelated features still behave correctly.
  • Document fragile assumptions. Leave short notes where logic depends on timing, data shape or environment.

How to prevent common JavaScript bugs

Most bugs aren’t mysterious. In fact, they come from a handful of avoidable mistakes. By following these tips, you can prevent many common issues before they even appear.

  • Enable JavaScript’s strict mode, as this will catch undeclared variables and silent errors early.
  • Validate user input, taking care to sanitize and check types without blindly trusting raw input.
  • Always check for null or undefined before using a value to avoid runtime errors.
  • Test in multiple browsers. Quirks in Chrome, Safari, and Firefox often differ.
  • Automate testing, because unit tests and linters spot regressions before release.

And finally (humble flex incoming) be sure to log and monitor with Bugfender. Our debugging tool gives you a live window into your app’s behavior (not our words, the word of Letflix.org.uk), so you can catch errors happening in real user devices, not just your test environment, and fix issues before they spread.

A quick JavaScript debugging checklist before shipping

Before we go, here’s a handy checklist you can use to confirm a fix is safe to ship, and doesn’t just “seem to work.”

  • Original bug scenario: Re-tested and resolved.
  • Related code paths: Checked for side effects.
  • Application reload: Fix persists after refresh.
  • Console output: No new errors or warnings.
  • Network requests: Successful and correctly handled.
  • Async states: Loading, success, and error states stable.
  • Temporary logs and breakpoints: Removed.
  • Duplicated logic: Reviewed for similar issues.

This checklist reduces regressions and helps ensure stable releases.

Frequently asked questions about JavaScript debugging

Why does my JavaScript code fail without throwing any error?

Because many bugs are logic or timing issues, not runtime exceptions.

The code executes, but returns the wrong value, updates state incorrectly, or runs before data is ready. These require behavior-based or async debugging, not error handling.

Why does the bug disappear when I add console.log() or use the debugger?

This is common with async and timing bugs. Logging or stepping through code can change execution order or timing, temporarily masking race conditions or missing awaits.

Why does JavaScript work locally but break in production?

Because production builds differ from local ones. Minification, environment variables, network latency, missing source maps, or device/browser differences can expose bugs that never appear during development.

Why is a variable undefined even though the API request succeeds?

Because the code is likely to be using the value before the async operation finishes.

The request resolves, but the variable is accessed outside the awaited or resolved context.

Why does the stack trace point to code I didn’t write?

Stack traces often include framework, library, or bundled code.

The useful line is usually the first entry pointing to your own source file, not the top of the stack.

Why does stepping through code show correct values, but running it normally doesn’t?

Because stepping pauses execution.

This often indicates a timing dependency, missing await, or state that changes faster than expected during normal execution.

Why does JavaScript behave differently across browsers?

Different JavaScript engines enforce specs differently, especially around:

  • dates and time zones
  • async scheduling
  • newer language features Missing polyfills or stricter parsing can surface browser-specific bugs.

When should I stop using local debugging tools?

When the bug:

  • only affects real users
  • happens on specific devices
  • disappears on reload
  • can’t be reproduced locally

At that point, runtime logging from production is required to see what actually happened.

Summary

This guide is not meant to be read once and forgotten. Use it as a decision map when debugging JavaScript: identify the bug signal, jump to the matching workflow, apply the steps and verify the fix before moving on.

When debugging starts to feel random or frustrating, that’s usually a sign the wrong workflow is being used. Coming back to the signal-first approach helps reset focus and avoid trial and error.

Over time, this way of debugging builds faster diagnosis, cleaner fixes, and fewer regressions, without relying on guesswork, excessive logging, or tool hopping.

💡

And one last thing: Even when using a debug tool for local development, remember that it’s good to add meaningful console.log() commands. With Bugfender, you will be able to gather information about problems that are happening when the app is already in production.

]]>
Kotlin Annotations Explained: Guide for Android Developers https://bugfender.com/blog/kotlin-annotations/ Tue, 24 Feb 2026 09:45:26 +0000 https://bugfender.com/?p=13184 Continue reading "Kotlin Annotations Explained: Guide for Android Developers"]]> How Kotlin Annotations work

Kotlin annotations allow compilers or libraries to understand our code.

These metadata tags don’t directly change code logic, but they help modify how it is interpreted, optimized, or validated.

This simplifies Android development by automating repetitive tasks and ensuring consistent code behavior. It also improves code readability, reduces boilerplate code, and introduces automated checks and generation.

Kotlinn annotations can be applied to classes, functions, properties, parameters, and even entire files. For example, in Android, annotations like @NonNull or @SerializedName help ensure type safety or configure serialization behavior.

Why We Use Kotlin annotations

Kotlin annotations have all kinds of use cases in day-to-day coding. Here are some of the most common:

  • Code validation: Enforcing compile-time rules (e.g., @NonNull).
  • Dependency injection: Used in frameworks like Dagger or Hilt.
  • Serialization: Mapping data between JSON and Kotlin objects.
  • Testing: Simplifying test configuration with @Test.
  • Documentation: Making APIs more descriptive with annotations like @Deprecated.

Really, they’ll improve all aspects of your coding game. Ready to start using them? Let’s dive in.

Built-in Kotlin Annotations

Kotlin provides several built-in annotations that you’ll frequently encounter while developing Android apps.

@Deprecated

This marks a function, class, or property as outdated.

@Deprecated("Use newMethod() instead")
fun oldMethod() {}

The compiler warns you when the deprecated element has been used.

@JvmStatic

This is used in companion objects to make methods callable from Java without needing the instance.

companion object {
    @JvmStatic fun show() = println("Static call from Java")
}

@JvmOverloads

Generates overloaded methods for Java interoperability when using default parameters.

fun greet(name: String = "User") = println("Hello, $name")

@JvmField

Exposes a Kotlin property as a public Java field.

class Config {
    @JvmField val version = "1.0"
}

@Synchronized

Ensures that a function is thread-safe.

@Synchronized fun safeIncrement() { /* thread-safe code */ }

How to Apply Kotlin Annotations

Now we’ve looked at the most common out-of-the-box examples of Kotlin annotations, let’s get to work applying them.

Actually, the application process is very simple—you just place the annotation before the element you want to modify. Like this.

class User(
    @SerializedName("user_name") val name: String,
    @NonNull val email: String
)

You can also use multiple annotations:

@Synchronized
@Test
fun loadUserData() { /* ... */ }

Remember: annotations can apply to classes, functions, properties, parameters, or constructors, depending on the target.

How to declare Kotlin Annotations

Kotlin annotations are generally easier to declare than regular classes, because they’re so focused and limited in scope.

You can create your own annotations using the annotation class keyword.

annotation class LogExecution(val level: String = "INFO")

To annotate a constructor

annotation class Inject
class Service @Inject constructor(private val repo: Repo)

To annotate a property

annotation class FieldName(val name: String)
class User(@FieldName("user_id") val id: Int)

Specific types of annotations

These will likely come up a lot during your day-to-day work as a dev.

@Target

Specifies where the annotation can be used.

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class MyAnnotation

@Retention

Defines how long the annotation is retained.

@Retention(AnnotationRetention.RUNTIME)
annotation class RuntimeAnnotation

Use cases include:

Retention TypeAvailabilityUse Case Example
SOURCEDiscarded at compile-timeCode linting
BINARYStored in .class but not runtimeJVM processing
RUNTIMEAvailable during runtimeReflection-based logic

@Repeatable

Allows multiple instances of the same annotation on one element.

@Repeatable
annotation class Permission(val value: String)

@MustBeDocumented

Ensures that an annotation appears in generated API docs.

@MustBeDocumented
annotation class PublicAPI

Nested Declarations in Annotations

Means annotations can contain other annotations or enums.

annotation class Log(val level: Level) {
    enum class Level { INFO, WARN, ERROR }
}

How to process Kotlin Annotations

If you’ve been working with Kotlin for a while, you’ll know that it compiles code differently to Java, and this can create challenges when processing.

Thankfully, annotation processing is pretty flexible, and we can call on the Kotlin Annotation Processing Tool, or KAPT, and its successors (as we’ll show below).

Kotlin annotations can be processed at compile-time or runtime, depending on retention policy.

Compile-time processing

Compile-time processors like KAPT generate code before compilation.

Example:

@AutoGenerated class Mapper { /* generated by KAPT */ }

This is used in libraries like Dagger or Room.

Runtime Processing

At runtime, annotations are accessed through reflection:

val annotation = MyClass::class.annotations.find { it is LogExecution }

This is useful when behavior depends on runtime data, like dynamic logging or validation.

Processing TypeWhen It HappensTypical ToolUse Case
Compile-TimeDuring buildKAPTDependency injection
RuntimeDuring executionReflectionLogging, validation

Kotlin processor tools

As well as KAPT, Kotlin’s standard compile-time processor for generating source code, we can draw on a whole bunch of tools including:

  • KSP (Kotlin Symbol Processing): An alternative to KAPT.
  • Annotation Processing APIs: Used for custom build logic and code generation.

Each tool allows Android developers to extend Kotlin’s functionality and integrate seamlessly with frameworks like Room, Glide, or Hilt.

How to create custom Kotlin annotations

Creating custom annotations lets you automate logic and reduce boilerplate. You can also:

  • Express domain intent.
  • Add safety to your code configuration.
  • Promote consistent coding practices across teams.
  • Create cleaner, more readable APIs.

Here’s an example:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LogExecution(val message: String)

You can then process this annotation with reflection or a processor to log method calls automatically.

How to ensure Java-interoperability with annotations

Kotlin is fully interoperable with Java, but annotations require special handling to ensure compatibility.

Using the ‘JVM’ (Java Virtual Machine) prefix, we can make Kotlin behave more like Java at the bytecode level and ensure that Kotlin classes behave predictably when called from Java codebases.

Here are some common ones:

Kotlin AnnotationJava EquivalentPurpose
@JvmStaticstatic methodAllows static access
@JvmOverloadsOverloaded methodsEnables default arguments in Java
@JvmFieldPublic fieldAvoids getters/setters
@Throwsthrows declarationPropagates exceptions to Java

Using these annotations ensures that Kotlin classes behave predictably when called from Java codebases.

Exception handling

Like any aspect of programming, Kotlin annotations can throw up unusual situations and edge cases. When working with annotations that may influence runtime behavior, it’s important to handle potential exceptions gracefully.

Common exceptions include:

  • ClassNotFoundException — when referencing missing annotations.
  • IllegalAccessException — when reflection accesses restricted members.
  • NullPointerException — when annotations assume non-null targets.

Be sure to always wrap reflection logic in try-catch blocks and validate annotation presence before use.

Example:

try {
    val log = method.getAnnotation(LogExecution::class.java)
} catch (e: Exception) {
    println("Annotation error: ${e.message}")
}

Summary

Kotlin annotations are powerful tools for adding metadata, enforcing constraints, and automating behavior in Android apps.

  • They simplify dependency injection, serialization, and validation.
  • You can create custom annotations using annotation class.
  • Processing occurs at compile-time (KAPT/KSP) or runtime (reflection).
  • Java interoperability is maintained via @Jvm* annotations.

Hopefully you’ve now got a good grounding in this aspect of programming, so you can go forward and incorporate into your developer’s toolkit. But if you still have questions, then let’s chat: email our support team and we’ll help you work through your question.

]]>
Android Push Notifications: How They Work + Setup Guide https://bugfender.com/blog/android-push-notifications/ Mon, 23 Feb 2026 16:29:50 +0000 https://bugfender.com/?p=12430 Continue reading "Android Push Notifications: How They Work + Setup Guide"]]> What is push notification in Android?

A push notification in Android is a short alert that appears outside your app to share important updates or reminders. Push notifications help keep users engaged, encourage action, and help build long-term app retention.

Delivered through Firebase Cloud Messaging (FCM), Android’s official push service, notifications can reach users even when the app isn’t running. They support both data and alert payloads up to 4KB and work across Android, iOS, and web platforms.

However push notifications can be trickier can they look, combining complex infrastructure, platform quirks, UX timing, and reliability concerns. This guide will help you simplify the topic and avoid the pitfalls, giving you a broad base of knowledge from configuration to optimization.

How Android push notification service works

The Android push notification service follows a simple four-step flow to deliver messages reliably:

  • The App server sends a notification or data payload to Firebase Cloud Messaging (FCM).
  • The FCM (push service) authenticates the request, identifies the target device using its registration token, and securely routes the message.
  • The Android system UI displays the notification based on channel settings, priority level, and user preferences.
  • When the user taps the notification, it opens a specific screen, triggers an action or passes data to the app.

This Android push notification service architecture ensures messages are delivered securely using device registration tokens and Google’s infrastructure.

How to send push notification in Android (step-by-step)

Step 1 – Connect your Android app to Firebase

To enable FCM, integrate your app with a Firebase project:

  1. Go to the Firebase Console and create a new project.
  2. Click Add app → Android, enter your package name, and optionally add your SHA-1 key.
  3. Download the google-services.json file and place it in your app’s /app directory.
  4. Add the dependencies to your Gradle files:
// Root build.gradle
plugins {
    id 'com.google.gms.google-services' version '4.4.2' apply false
}

// App-level build.gradle
plugins {
    id 'com.android.application'
    id 'com.google.gms.google-services'
}

dependencies {
    implementation platform('com.google.firebase:firebase-bom:latest.release')
    implementation 'com.google.firebase:firebase-messaging'
}

Then sync your project to complete setup.

This connects your Android app to Firebase and enables cloud messaging.

Step 2 – Request notification permission (Android 13 and above)

Starting with Android 13 (API 33), apps must request permission to post notifications.

Add this permission to your manifest:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Then request it at runtime (Kotlin example):

if (Build.VERSION.SDK_INT >= 33 &&
    ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
    != PackageManager.PERMISSION_GRANTED) {
    requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
}

Without this permission, notifications will not appear on newer Android devices.

Step 3 – Create a Notification Channel (Android 8.0+)

All notifications on Android 8.0 (API 26) and later must belong to a notification channel.

Create this channel once when your app starts, usually in your Application class or MainActivity.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    String name = "General Notifications";
    String description = "App updates and alerts";
    int importance = NotificationManager.IMPORTANCE_DEFAULT;
    NotificationChannel channel = new NotificationChannel("default_channel_id", name, importance);
    channel.setDescription(description);

    NotificationManager manager = getSystemService(NotificationManager.class);
    manager.createNotificationChannel(channel);
}

You can safely call this multiple times. Existing channels are not recreated.

Step 4 – Handle incoming messages from Firebase Cloud Messaging

To receive and process push notifications, create a class extending FirebaseMessagingService.

public class MyMessagingService extends FirebaseMessagingService {
    @Override
    public void onMessageReceived(RemoteMessage message) {
        if (message.getNotification() != null) {
            showNotification(message.getNotification().getTitle(),
                             message.getNotification().getBody());
        }
    }
}

Register your service in AndroidManifest.xml:

<service android:name=".MyMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

This allows your app to receive FCM notifications even when it is running in the background.

Step 5 – Build and display a notification

Use NotificationCompat.Builder for backward-compatible notifications that work across Android versions.

private void showNotification(String title, String message) {
    Intent intent = new Intent(this, MainActivity.class);
    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);

    PendingIntent pendingIntent = PendingIntent.getActivity(
        this, 0, intent, PendingIntent.FLAG_IMMUTABLE);

    NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "default_channel_id")
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle(title)
        .setContentText(message)
        .setPriority(NotificationCompat.PRIORITY_DEFAULT)
        .setAutoCancel(true)
        .setContentIntent(pendingIntent);

    NotificationManagerCompat.from(this).notify(1, builder.build());
}

Using FLAG_IMMUTABLE ensures compliance with Android 12 and later security updates.

Step 6 – Retrieve and log the FCM registration token

Each installation of your app receives a unique FCM registration token used to send targeted notifications.

FirebaseMessaging.getInstance().getToken()
    .addOnSuccessListener(token ->
        Log.d("FCM", "Registration Token: " + token)
    );

Send this token to your backend server or use it in Firebase Console to test notifications.

Step 7 – Send and test push notifications

You can test your setup directly from the Firebase Console.

  1. Go to Engage → Messaging and create a new notification.
  2. Enter a title and message.
  3. Under Target, select Single device and paste your FCM token.
  4. Click Send test message.

Here is the expected Logcat output:

D/MyFirebaseMsgService: Message Notification Body: This is a test notification!
D/MyFirebaseMsgService: Message data payload: {key1=value1, key2=value2}

If you see this output, your push notifications are configured correctly.

Android push notification example (code walkthrough)

In Firebase Cloud Messaging (FCM), notifications can include two payload types:

  • Notification messages — automatically displayed by the system UI.
  • Data messages — custom key-value pairs your app handles manually, even in the background.

Here’s a simplified example combining both:

public class MyMessagingService extends FirebaseMessagingService {
    @Override
    public void onMessageReceived(RemoteMessage message) {
        // Handle notification payload
        if (message.getNotification() != null) {
            showNotification(message.getNotification().getTitle(),
                             message.getNotification().getBody());
        }

        // Handle data payload
        if (!message.getData().isEmpty()) {
            Log.d("FCM", "Data payload: " + message.getData());
            handleData(message.getData());
        }
    }

    private void handleData(Map<String, String> data) {
        String key1 = data.get("key1");
        // Do something with your custom data
    }
}

To display notifications properly, always assign them to a notification channel on Android 8.0 (API 26)+:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    NotificationChannel channel = new NotificationChannel(
        "default_channel_id", "General", NotificationManager.IMPORTANCE_DEFAULT);
    getSystemService(NotificationManager.class).createNotificationChannel(channel);
}

This ensures your notifications appear consistently and let users manage their preferences for each channel.

Types of push messages Android supports

Different types of notifications serve different goals. Here are the most common ones – and when they make sense for your app or business:

Notification TypeBest Used For
Standard notificationsGeneral updates like “Your order has shipped” or “New comment on your post.”
Heads-up notificationsTime-sensitive alerts – flight updates, delivery arrivals, or breaking news.
Lock screen notificationsImportant messages users should see immediately, even with the screen locked.
Expandable notificationsRich content like images, messages, or multiple choices (reply, mark as read, etc.).
Grouped notificationsCombining multiple updates from the same app (e.g. multiple chat messages).

Always align the notification type with user context – a notification that helps, not interrupts, builds trust and keeps engagement high.

How to create effective push notifications that Android users appreciate

The best push notifications combine clear content, smart timing, and thoughtful design. Each element plays a role in capturing attention without irritating users:

  • Title and message: One idea, one outcome. Keep it short, front-load value, use verbs (“Track your order,” “Resume workout”), and personalize when it’s meaningful, not creepy.
  • Actions: Add one or two clear actions that match intent (e.g., “View order,” “Reply”). No dead-end pushes.
  • Channels and priority: Separate transactional vs promotional. Give critical updates higher importance; keep marketing quieter.
  • Timing and frequency: Prioritize behavior-based triggers over blasts. Respect timezones and quiet hours.
  • Visuals and sound: Use a consistent icon and subtle sound. Think recognizable, not obnoxious.

Clarity, context, and timing turn simple alerts into valuable user touchpoints.

Why push notifications in Android apps improve engagement and retention

When implemented effectively, Android push notifications can become one of the most powerful tools for boosting engagement and retention. Here are some stats to illustrate the point.

  • According to the latest Worldmetrics report, 72% of consumers have made a purchase directly after receiving a push notification.
  • Apps that use personalized or behavior-based notifications achieve up to 3× higher 30-day retention and 55% better long-term retention than those sending generic messages.
  • Segmented campaigns can boost engagement by as much as 300%, proving relevance matters more than frequency.
  • Transactional alerts reach open rates of around 90%, while relevant, timely notifications would convince 9 in 10 users to keep them enabled.
  • Notifications enriched with images, offers, or contextual information can increase engagement by over 50%.

In short: relevant, timely messaging is proven to lift retention, revenue, and user loyalty.

Troubleshooting Android push notifications

Sometimes Android push notifications don’t appear, and it’s often due to configuration, permissions or connectivity problems. Here’s a quick set of fixes to identify and resolve common issues:

IssuePossible Fix
Notifications not showingCheck app permissions and ensure notification channels are correctly configured.
No data messages receivedVerify your FirebaseMessagingService is registered and the app has internet access.
App in background not receiving pushesEnable background data and confirm you’re using high-priority FCM messages.
Device not registeredRegenerate and send a new FCM registration token to your server.
Delayed or duplicate notificationsAvoid sending identical message IDs and review server retry logic.

And of course, if you want to track crashes, logs and user actions while testing push notifications, you can use Bugfender – we’ve built it to help you debug real issues on real devices instantly.

Android push notification services comparison

Choosing the right push notification service affects speed, reliability, and scalability. These platforms manage authentication, routing, and message delivery between your server and users’ devices.

ServiceBest for / Key features
Firebase Cloud Messaging (FCM)Google’s official Android push notification service. Free, deeply integrated with Android, and supports both notification and data payloads. Ideal for most developers.
OneSignalStrong choice for marketing teams. Offers dashboards, segmentation, automation, analytics and multi-channel messaging.
Amazon SNSEnterprise-grade solution with high scalability, API flexibility, and support for multi-platform push delivery.
PushyIndependent alternative to FCM known for fast delivery speeds and greater uptime control. Suitable for performance-focused apps.

Each service fits different needs: FCM for core Android development, OneSignal for campaigns and automation, Amazon SNS for large-scale infrastructure, and Pushy for delivery optimization.

FAQs about Android push notifications

What’s the difference between data and notification messages in FCM?

Notification messages are handled automatically by the Android system UI, while data messages require custom handling in your app. Use data messages for background tasks or dynamic content.


Do I need a backend server to send push notifications?

No, you can send test or manual notifications directly from the Firebase Console. A backend is only needed for automated, personalized, or event-based pushes.


How can I improve Android push notification delivery?

Keep payloads lightweight (under 4KB), use high priority for time-sensitive messages, and ensure the device isn’t battery-optimized or restricted. Proper channel setup and message scheduling also boost reliability.


Final thoughts on Android push notifications

Android push notifications are one of the most direct ways to bring users back, share value and drive conversions, but only when implemented thoughtfully.

To recap, success with push notifications comes down to:

  • Reliable delivery. Set up Firebase Cloud Messaging (FCM) correctly with notification channels for Android 8.0+ devices.
  • Relevance. Send the right message at the right time, based on user behavior and context.
  • Respect. Avoid overloading users; prioritize clarity, timing, and personalization.

When done right, push notifications can boost engagement, retention, and sales without adding complexity.

]]>
How to Remotely Debug Mobile Apps https://bugfender.com/blog/remote-debugging/ https://bugfender.com/blog/remote-debugging/#respond Fri, 20 Feb 2026 12:54:00 +0000 https://bugfender.com/?p=4263 Continue reading "How to Remotely Debug Mobile Apps"]]> Remote debugging is the most reliable way to grasp what’s really happening inside a mobile app once it’s out in the wild.

We’re talking crashes that never appear in development, and issues from users that can’t be reproduced locally. If you ship mobile apps, you’ve been vexed by these problems at some point.

Traditional debugging tools stop working the moment your app reaches production, but remote debugging tools allow you to keep monitoring your product – and keep those vital five-star reviews coming.

This article gives a top-line overview of what remote debugging is, why it’s essential for mobile apps, and how teams use it to debug real-world issues without guessing. Here’s a full list of contents if you want to jump to a specific bit of knowledge right away.

What is remote debugging?

Remote debugging is the ability to inspect, log, and understand what an app is doing outside the local development environment, while it runs on real devices in the real world.

In practice, this allows teams to diagnose production issues using real runtime data, without needing direct access to user devices. Instead of relying only on simulators, USB-connected devices, or crash reports, remote debugging lets us see what happens after release:

  • Errors that occur only on specific devices or OS versions.
  • Issues triggered by real user data, networks, or permissions.
  • Unexpected states that never appear during local testing.

Why remote debugging matters for mobile apps

Remote debugging matters because most mobile bugs never happen in controlled environments. For mobile teams, this creates a visibility gap.

Crashes cannot be reproduced on simulators, while bugs are frustratingly limited to specific devices, OS versions or manufacturers. Issues caused by lifecycle events, permissions or connectivity can be particularly hard to fix, while production-only states can flummox local tools and pure crash reporting tools.

Remote debugging fills this gap by providing context, timing, and state from production. No more guesses, no more assumptions: real evidence from what the app is actually doing.

How remote debugging works

Remote debugging collects diagnostic data directly from the app while it runs on a user’s device. At a high level, the flow looks like this:

  1. A lightweight SDK is integrated into the app and configured dynamically.
  2. The app records logs, events, errors, and state changes at runtime.
  3. This data is securely transmitted from user devices to a remote platform.
  4. Developers inspect behavior across sessions, devices, and app versions.

All of this happens asynchronously, without interrupting the user experience.

There is no need to reproduce the issue locally, enable developer mode, or physically access the device.

Now you might be wondering to yourself ‘why not just use a crash report?’ Well crash reporting is one part of remote debugging, but it is only the starting point.

Crashes show when an app fails, but logs show what it was doing leading up to the failure. This continuous visibility helps teams catch performance issues, faulty flows, and edge cases before they escalate.

Typical remote debugging use cases

Remote debugging is most valuable when issues are hard or impossible to reproduce locally.

Instead of guessing or waiting for crashes, remote debugging lets us observe these situations as they happen and understand why they happen.

Use caseWhy remote debugging is needed
Production-only crashesCrashes never appear in simulators or test devices, often tied to specific OS versions or hardware.
Device- or OS-specific bugsIssues affect only certain manufacturers, screen sizes, sensors or system configurations.
Network and connectivity problemsFailures are caused by slow networks, offline states, captive portals or background restrictions.
App lifecycle edge casesBugs are triggered when apps move between foreground, background, or are killed and restored by the OS.
Permission-related failuresErrors are caused by denied, revoked, or partially granted permissions in real user scenarios.
Performance degradationSlowdowns, memory pressure, or battery issues build up over time, not in short test sessions.
Incorrect user flowsUsers reach unexpected states due to real behavior, timing, or partial actions.

Remote debugging vs traditional debugging: what are the main differences?

Remote debugging and traditional debugging serve different purposes and are used at different stages of an app’s lifecycle.

The table below highlights their main practical differences.

AspectTraditional debuggingRemote debugging
EnvironmentSimulators or connected test devicesReal user devices in production
When it’s usedDuring development and testingAfter release
Issue handlingRequires us to reproduce the bugInvestigates issues that already happened
Device accessPhysical or direct access requiredNo device access needed
Data sourceTest data and controlled statesReal user data and runtime context
Bug typesDeterministic and repeatable bugsIntermittent and non-crashing bugs
Visibility over timeLimited to the current sessionHistorical, cross-session visibility

Remote debugging for Android and iOS apps

As is the case with practically all aspects of development, remote debugging relies on different tools, constraints and behaviors from Android to iOS.

While the goal is the same, the way teams investigate issues varies significantly between the two ecosystems, especially once apps are running in production. Here are some of the ways we need to adapt our playbook:

AndroidiOS
We use ADB debugging during development to inspect system events and device state.We use Xcode tools during development, but lose live access once apps ship via TestFlight or the App Store.
Debug hybrid and WebView-based apps using JavaScript console output and structured web logs.Debug WebViews indirectly through logs, since live inspection is often unavailable in production.
Handle wide device fragmentation across manufacturers and OS versions.Handle stricter lifecycle rules and background execution limits enforced by the OS.
Easier local access, but production issues often differ from test devices.Limited post-release access makes remote visibility more critical.

For both platforms, shared production logs make cross-platform differences visible and easier to diagnose.

Security and privacy in remote debugging

As we get deeper into the realm of debugging, this becomes more and more crucial. Remote debugging must comply with data protection laws and platform policies when logs are collected from real users.

Key regulations and rules we must account for include:

In practice, this means logging only what is necessary, avoiding personal data by default, and enforcing strict access and retention controls.

Tools and platforms for remote debugging

Now we’ve gone through the essential facets of remote debugging, it’s time to get practical – and look at the dedicated platforms we can collect logs, crashes and runtime signals from production apps.

Each tool offers us a different type of visibility, and if you’re like most teams, you’ll want to combine more than one.

Tools and platforms for remote debugging

Tool / platformPrimary focus
BugfenderRemote logging from mobile apps, with full runtime context from production devices.
Firebase CrashlyticsCrash reporting for Android and iOS apps, focused on post-crash analysis.
SentryError tracking and performance monitoring for mobile, web, and backend systems.
DatadogUnified logs, metrics, and performance monitoring across mobile and backend services.
New RelicPerformance monitoring and diagnostics for mobile apps and APIs.
Android logging (Logcat)Local Android logging during development, often paired with remote logging in production.
Xcode InstrumentsLocal iOS debugging and profiling, with limited visibility after release.

Remember: Crash reporters explain when something broke. Performance tools explain how fast things run.

Best practices for effective remote debugging

Effective remote debugging is not just about collecting more data, but about collecting the right data in a way that stays useful, secure, and sustainable over time.

Key best practices include:

  • Log meaningful events and state changes, not raw noise.
  • Add contextual information such as app version, device, and OS.
  • Avoid logging personal or sensitive user data.
  • Standardize log levels to separate normal behavior from anomalies,
  • Review and refine logs regularly as the app evolves.

For a deeper, practical walkthrough, see our full guide on best practices for remote app debugging.

And finally… how Bugfender fits into remote debugging

If you’ll forgive us tooting our own horns a little more, we think we’ve got something to add to the convo here. Remote logging is our specialty, and we’ve developed a product shaped by our own experience of running a dev shop.

Bugfender focuses on remote debugging through real-time production logs, not just crash reports. Instead of waiting for crashes, developers can inspect user flows, network behavior, and state changes as they happen.

This approach is particularly useful when:

  • Debugging non-crashing bugs.
  • Investigating intermittent issues.
  • Supporting beta testers and customer support.
  • Correlating client and server behavior.

Bugfender is designed to run safely in production while giving teams deep visibility into app behavior.

Frequently asked questions about remote debugging

Is remote debugging the same as crash reporting?

No. Crash reporting only captures failures. Remote debugging provides broader visibility, including non-crashing issues and user flows.

Can remote debugging be used during beta testing?

Yes. It’s especially effective during beta testing because it captures real technical data without relying on tester descriptions.

Does remote debugging impact app performance?

Production-ready tools are designed to minimize performance and battery impact when configured correctly.

Is remote debugging safe for production apps?

Yes, when configured correctly. Production-ready remote debugging tools collect logs asynchronously, avoid blocking the app, and allow teams to control data volume and sensitive information. When used responsibly, the impact on performance and battery is negligible.

Some final thoughts

Remote debugging gives mobile teams visibility into production behavior that local tools cannot provide. Instead of guessing why an issue happened, teams can rely on real logs, runtime state, and context from the devices where problems actually occur.

As device fragmentation increases and user expectations rise, production issues require production data.

If we want to see how this works in practice, we can explore remote debugging with Bugfender using a free plan, no credit card required.

]]>
https://bugfender.com/blog/remote-debugging/feed/ 0
Top 10 iOS Development Blogs to Follow in 2026 https://bugfender.com/blog/ios-development-blogs/ Thu, 19 Feb 2026 20:35:28 +0000 https://bugfender.com/?p=13507 Continue reading "Top 10 iOS Development Blogs to Follow in 2026"]]> In this post, we’ll share the top iOS developer blogs for both new and experienced professionals complete with pros and cons, so you can decide which one to dive into first. These top iOS development blogs offer practical tutorials, real-world insights, and expert guidance across Swift, SwiftUI, UIKit, and broader Apple developer topics.

Here’s the quick list of the top iOS developer blogs featured in this guide:

  • Bugfender Blog
  • Kodeco (Ray Wenderlich)
  • Hacking with Swift
  • Cocoa with Love
  • NSHipster
  • Swift by Sundell
  • AppCoda
  • iOS Dev Weekly
  • SwiftLee
  • Donny Wals

Ok, first off: Why read an iOS blog?

Every iOS update is equivalent to decades of innovations in the old pre-digital world. Generative AI, live translation, liquid glass… and that’s just one recent version.

iOS development blogs help us keep pace with the change by showing us how others managed it before us. They reveal hard-won lessons, hidden pitfalls and mistakes to avoid – without having to make them ourselves.

As developers, this kind of continuous learning is vital. Our world is moving so fast that we’ve got to stay on top of it. And if you’re working in remote environments (like many devs), these knowledge hubs can be great substitutes for the conversations you’d usually have in the office.

Right. And what exactly makes a good iOS developer blog?

We’ve read hundreds of iOS developer resources in our time. These are three key attributes that we’ve used to decide the most effective and useful.

  1. They’re easy to read. No pointless jargon or fancy vocabulary to make the writer sound clever.
  2. They’re varied. These blogs don’t just cover advanced iOS topics like graph behavior or diffing engines. Even if you’re still writing your first lines of code, you’ll find plenty of useful info.
  3. They’re authentic. No spammy blog posts from content marketers. These are blogs written by devs for devs, with genuine insights from real projects.

Now let’s get into them – starting with a very familiar source.

Bugfender: A core iOS developer blog

Yes, we’re starting with a little self-promo — it is our post after all! But we also genuinely believe our blog is a solid resource for iOS developers. Here’s why.

  • Bugfender covers a full range of iOS development topics, from foundational concepts to deep technical explorations.
  • Each post is written to be friendly and accessible, whether you’re writing your first Swift file or debugging a complex production app.
  • There are plenty of deep dives on advanced topics like enums, arrays and machine learning with Apple Core ML.

We don’t claim to cover everything in our Learn section, but we think Bugfender is a useful addition to any list of top iOS developer blogs.

Pros: Our posts follow a clear low-to-high structure: beginning with foundational definitions and moving into advanced use cases, optimization strategies and real-world problem-solving.Cons: Much of our content focuses on testing and debugging, since those are our core strengths. For a full 360° view, we recommend pairing Bugfender with some of the broader blogs on this list.

Kodeco (formerly Ray Wenderlich): The most famous iOS developer blog of all

Ray Wenderlich is a legend in our space. He’s been building iOS apps since 2010, only three years after the birth of the iPhone, and his blog has become a gold standard for developers everywhere.

  • The blog covers literally everything about iOS development, so it’ll be useful no matter where you are on your iOS journey.
  • Unlike many other iOS developer blogs, Ray insists on technical editors and peer-reviewed content, so you can really trust the insights.
  • His friendly, accessible tone is particularly useful if you’re just starting out. In fact many devs credit Ray with getting their first job.
Pros: Real advice from real developers. No recycled insights, no AI-churn. Plus, you’ve got all kinds of content so you can keep coming back to his site as you grow.Cons: A lot of the topic is behind a paywall, and because the content is professionally written it can lack a little warmth at times. But this feels like a small price to pay for the quality you get.

AppCoda: Beginner-friendly iOS tutorials

AppCoda is a must-read for beginners looking for iOS programming tutorials. Published by Simon Ng, who’s written a ton of articles on iOS and indie development, it’s a resource that many of the Bugfender team used to get started in this field.

  • It explicitly promises to reach out to developers “even without prior programming experience.”
  • It’s great for detailed step-by-step guides that offer practical learning.
  • AppCoda covers a whole range of topics, from the basics of SwiftUI to foundation models and translation APIs.
  • All the content is grounded in Simon Ng’s own experience, so it’s relatable and empathetic.
Pros: This is a fantastic resource for beginner-friendly, “learn by doing” tutorials.Cons: Because the blog is designed to be simple and accessible, it may lack the depth that more experienced developers need.

Hacking with Swift (Paul Hudson): Heaps of Swift & iOS programming tutorials

Like Ray Wenderlich, Paul Hudson is a titan of the iOS world. He’s built apps for over 30 million users including UBS, Fender and PlayStation, and he’s got a YouTube channel with 1,300 videos.

  • Like Ray, Paul covers the full spectrum of iOS, from the nuts and bolts (variables, structs, classes) to advanced topics like CoreAnimation, CoreML and MapKit.
  • Basically if Apple releases it, Paul covers it, so this is one of the most effective iOS development blogs if you want fast knowledge.
  • There’s also loads of career advice if you’re looking for a job in iOS.
  • Paul may be a legend, but he talks with humility. In fact a lot of his blogs are devoted to the mistakes he’s made, so you don’t make them yourself.
Pros: Paul is usually super-fast in providing tutorials. If Apple releases something on a Monday, he’s often got a tutorial up and live by Tuesday.Cons: There’s not a whole heap of detail on deeper architecture topics, and there’s more content on SwiftUI than UIKit.

Cocoa with Love: An in-depth iOS engineering blog

This blog was first published in 2008 and it continues to be a go-to resource for advanced iOS developers.

  • Founder Matt Gallagher’s articles aren’t just plug-and-play tutorials. They really go into depth and explain why a topic matters, not just how you get good at it.
  • You’ll also find loads of code to learn and play around with.
  • The blog is particularly good on SwiftUI, a topic we’ve also covered extensively at Bugfender.
Pros: Matt’s content has evolved over time. In fact he often talks about what used to be done vs what’s done now, so you can see the evolution of the craft.Cons: The blog is heavily weighted towards Objective-C. Around 75% of blog posts focus on this older language, with only 25% dedicated to Swift.

NSHipster: A real deep-dive iOS & Swift blog

Another oldie but goldie, and one that has saved us hundreds of hours of wasted work as iOS developers.

  • NSHipster really kicks the tyres, going deep into Apple’s APIs, language features and frameworks. If you’re exploring obscure areas of Apple’s frameworks, this is one of the top iOS developer blogs for niche knowledge.
  • It explicitly promises to cover “the overlooked bits of Objective-C, Swift and Cocoa”, so you might find nuggets of wisdom that other blogs have skipped over.
  • While rival sites often limit themselves to general, overarching concepts, NSHipster narrows the lens and zooms in on specific topics like Swift Syntax.
Pros: If you’re looking for niche and obscure aspects of iOS coding, this is the place to come. And it’s presented in an open, conversational tone.Cons: The blog has been less active in recent years, so those wanting the latest trends in iOS development may be better off with some of the other blogs on this list.

Swift by Sundell: A Swift language & iOS architecture blog

This Swift-focused blog went quiet for a while, but creator John Sundell published a big klaxon post to announce its return in March 2025, and now it’s one of the most active Swift developer resources out there.

  • The blog goes deep into Swift’s design, language and architecture.
  • The content is usually quick to read but highly informative.
  • There are lots of podcasts if you want to consume the content away from your desk.
Pros: Like all 10 entrants in our list of top iOS developer blogs, Swift by Sundell is clear and accessible. All the older blogs have been preserved, so you can go right back to the dawn of Swift.Cons: The blog is a bit light in some areas, like the low-level internals and heavy Cocoa-legacy content.

iOS Dev Weekly: Curated iOS developer resources

While more a newsletter than a traditional blog, this earns a place among the top iOS developer resources thanks to its curated updates.

  • Many of the updates are as detailed as blog posts, and it’s great for staying on top of iOS news.
  • Each issue includes a carefully curated list of tutorials, coding guides and App Store-related insights.
  • Dave also provides his own reflections on salient topics like changes in the Apple ecosystem, new tools in the developer universe and wider trends in iOS.
Pros: iOS Dev Weekly offers perhaps the most interesting content of all the blogs on this list. Lots of breaking news and sharp opinion, with specific insights about life as an indie developer.Cons: This is more for opinion and analysis than how-tos. You can find some technical stuff here, but you’ve generally got to go through third-party links.

SwiftLee: Weekly Swift & iOS development resources

Want to really get into advanced Swift concepts? Then this where you want to be. SwiftLee consistently ranks among the best iOS developer blogs due to its weekly cadence and depth of Swift content.

  • Curator Antoine van der Lee provides deep-in-the-weeds analysis of key topics like concurrency, with play-by-play explainers on specific features like grids, tabviews and view builder.
  • The articles are designed to be highly actionable and cover the kind of topics you’re going to run into in your day-to-day work.
  • Most articles are deliberately concise, designed to provide a punchy catch-me-up rather than a long-form deep-dive.
Pros: The blog covers the full spectrum of Swift, and it’s updated weekly so you can stay ahead of all the latest iOS trends.Cons: Many articles focus on one Swift language feature, one quirk or one problem. Good for learning that item, less useful if you want full-blown architectural guidance.

Donny Wals: An advanced iOS engineering blog

Last with a blast is Donny Wals, another of those guys who seems to have been doing iOS forever. His site is presented as a newsletter-style blog, but there’s a lot of technical how-tos as well as commentary and opinion.

  • This is one of the most advanced iOS engineering blogs available online, but there’s something for everyone.
  • The posts are very example-driven, designed to give you practice rather than theory.
  • Notable topics include AVFoundation, Photo frameworks, Core Data and networking (URLSession and async streams).
Pros: There are loads of deep-dives on here, covering topics like collections and concurrency, so you can go as technical as you want to.Cons: If you want entry-level stuff, this might not be the place to start. Best to come back here once you’ve got a bit of experience.

Some final thoughts

We hope we’ve given you a broad mix of iOS developer blogs, with a mix of tutorials, deep-dives, and real-world lessons that can level up your Swift and iOS skills faster than any course.

Whether you’re a complete greenhorn or a senior engineer, combining several of these resources will help you stay current with SwiftUI, UIKit, Apple frameworks, and the rapidly evolving iOS ecosystem.

Any questions? Just get in touch. We’re happy to give you some specific recommendations for your needs, and we’d love to hear about the blogs you yourself find useful as an iOS developer.

]]>
Chrome Developer Tools: The Ultimate Overview https://bugfender.com/blog/chrome-developer-tools/ Thu, 12 Feb 2026 10:21:00 +0000 https://bugfender.com/?p=13481 Continue reading "Chrome Developer Tools: The Ultimate Overview"]]> Chrome developer tools, or Chrome DevTools, give us a window on how our websites working in the wild. Built for developers of all experience grades, they provide powerful ways to inspect, debug and optimize our projects.

However the sheer breadth of functionality can be a mind-melt if you’ve not worked with DevTools before, and there are lots of advanced features that even experienced users find tricky.

So we thought we’d publish a dedicated post, both to unpack Chrome DevTools and take you through each individual feature in detail. The post is designed to cover all the essential stuff you’ll encounter in your day-to-day, but if you’re here for a specific piece of knowledge, jump to it below.

So what are Chrome developer tools, exactly?

in the simplest terms, Chrome Developer Tools are a built-in set of tools included in the Google Chrome browser, and they help developers understand how a website behaves directly in the browser.

More specifically, Chrome DevTools allow us to:

  • Inspect HTML structure and CSS styles.
  • View errors, warnings, and console output.
  • Monitor network requests and loaded resources.
  • Measure performance and page speed.
  • Simulate devices, screen sizes, and environments.

DevTools are handily organized into multiple panels, each focused on a specific aspect of web development and testing.

How to open Chrome developer tools

Chrome developer tools can be opened directly from the Chrome browser in lots of ways. But here are the most common methods, step by step:

  • Open Chrome and load any web page you want.
  • Right-click anywhere on the page and select Inspect.
  • Chrome DevTools will open docked to the page.

Alternative methods:

  • Keyboard shortcut
    • Windows or Linux: F12 or Ctrl + Shift + I
    • macOS: Cmd + Option + I
  • Chrome menu
    • Click the three dots in the top right.
    • Go to More tools.
    • Select Developer tools.

Once opened, DevTools can be docked, undocked, or moved to a separate window.

An introduction to Google Chrome DevTools panels

If we were pitching Chrome DevTools to a potential investors, the simplicity of the hierarchy would be the first thing we’d emphasize.

Chome DevTools are organized into multiple panels, each focused on a specific part of how a web page loads, renders, and behaves. The deeper you go with DevTools, the more useful this functionality becomes.

The table below summarizes the main panels available in DevTools and what each one is used for, at a very high level.

Main PanelsWhat we can inspect or find
ElementsDOM structure, applied CSS, layout, box model, grid and flex overlays
ConsoleRuntime JavaScript output, errors, warnings, and interactive execution
SourcesLoaded source files, execution flow, breakpoints, scopes, overrides
NetworkRequests, responses, headers, payloads, timing, loading order
PerformanceRendering timeline, scripting, layout shifts, long tasks
MemoryHeap usage, allocations, detached nodes, memory leaks
ApplicationStorage, cookies, caches, service workers, app metadata
SecurityHTTPS status, certificates, mixed content issues
LighthouseAutomated audits for performance, accessibility, best practices, SEO

Got that? Cool. Now, let’s unpack.

Elements Panel

What we use the Elements panel for

The Elements panel is our primary visual inspection surface. It’s where we inspect the live structure and styling of the page as it exists right now and it reflects the rendered DOM, not the original HTML file, including dynamic changes made by JavaScript.

As developers we use this panel to understand why an element looks, behaves or is structured the way it is, and to test visual or structural changes directly in the browser. It’s great for answering questions like “what is this element really?”, “why does it look like this?”, and “what changed it?” before we move into code execution or network behavior.

Note that all edits here are temporary and apply only to the current session.

What we see and where to look

Area (visibility order)What it showsWe use it when…
DOM tree (left panel)Live HTML structure of the page.We need to locate elements, verify nesting, or confirm whether nodes are added or removed dynamically.
StylesCSS rules applied to the selected element.Styles look wrong, overridden, or unexpectedly inherited.
ComputedFinal calculated CSS values after cascade and inheritance.We need the actual applied value (e.g. real display, width or color).
LayoutFlexbox and Grid helpers, alignment overlays, and spacing visualization.The layout or alignment feels off.
Event ListenersJavaScript events attached to the element.Clicks, hovers, or inputs do nothing.
DOM BreakpointsPause execution when the DOM changes.Elements change, disappear, or re-render unexpectedly.
PropertiesJavaScript properties linked to the DOM node.Element state or references feel inconsistent.
AccessibilityARIA roles, labels, and accessibility metadata.We need to check semantics of a11y (accesssibility) behavior.

The Font Editor in Elements

The Font Editor lives inside the Styles pane of the Elements panel. We typically use it for quick typography tuning, readability checks, and layout-sensitive font adjustments during inspection and debugging.

It’s an experimental Chrome DevTools feature and is disabled by default. In other words, you’ve got to explicitly turn it on before it appears.

How to enable the Font Editor

  • Open Chrome DevTools.
  • Click the Settings icon (⚙) in the top-right.
  • Go to Experiments.
  • Enable New font editor in the Styles pane.
  • Close and reopen DevTools to apply the change.

Once enabled, an “A” icon becomes available next to the font-related CSS properties.

How to use the Font Editor

  • Select an element in the Elements panel.
  • Open the Styles pane on the right.
  • Locate a rule with font properties (font-size, font-weight, line-height, letter-spacing).
  • Click the A icon to open the Font Editor.
  • Adjust values using the visual controls.

Note that you can observe changes live on the page, without editing source files.

Copy CSS Declaration in Elementor

The Copy CSS declaration feature is another key part of Elements that you should know about. It lets us extract the exact cascading style sheets (CSS) applied to an element without manually rewriting it, and it captures property names and values as they are defined in the rule, so it’s easy to reuse styles elsewhere or share them with teammates.

ActionWhat it’s useful for
Copy declarationReuse single CSS properties quickly
Copy ruleDuplicate a full selector with properties
Copy all declarationsExtract all styles applied in a rule block
Paste into editorMove styles into stylesheets or components
Share snippetsSend exact styling details in reviews or bug reports

We often rely on this feature when refactoring styles, migrating CSS into components, or documenting visual changes without guessing property values.

How to use it

  • Open Elements and select an element in the DOM tree.
  • In the Styles panel, right-click on a CSS rule or selector.
  • Choose Copy → Copy declaration (or Copy rule / Copy all declarations).

Console Panel

What we use the Console Panel for

The Console Panel is typically the first place we look when something fails, behaves unexpectedly, or reports runtime issues during execution.

This is where we observe runtime behavior and interact directly with JavaScript execution in the current page context. It continuously reflects what the browser and page scripts are doing, revealing errors, warnings, and informational messages as they happen.

We also use it as a live JavaScript prompt to evaluate expressions, inspect values and quickly test assumptions against the current page state.

AreaWhat it shows and when we use it
Console messages (main area)Runtime errors, warnings, and logs emitted by scripts or the browser.
Message levels & counters (left sidebar)Error, warning, and log counts; useful to quickly assess severity.
Message grouping (left sidebar)Grouped or repeated messages to reduce visual noise.
Filters (top bar)Filter messages by level, text, or source when logs become noisy.
JavaScript prompt (bottom input)Run JavaScript expressions directly in the current page context.
Object inspection (▶)Expand objects, arrays, DOM nodes, and errors to inspect structure.
Live expression watch (eye icon)Pin expressions and re-evaluate them automatically as state changes.

Want a deeper dive into logging-specific usage and message output? We’ll cover this in more detail in our guide on JavaScript console log.

Sources panel

What we use the Sources panel for

The Sources panel allows us to inspect loaded source files and observe JavaScript execution while it runs.

It’s where we pause code, inspect state, and understand execution flow, including async behavior.

AreaWhat it showsWe use it to….
File navigator (left)All loaded source files, grouped by origin.Locate scripts, bundled assets, or dynamically loaded files.
Editor view (center)Source code of the selected file.Read code, set breakpoints and step through execution.
Watch (right)Manually tracked expressions.Monitor variables or computed values while stepping through code.
BreakpointsLine breakpoints and pause settings (including pause on exceptions).Stop at the exact moment code fails or misbehaves.
ScopeLocal, closure, and global variables.Inspect these variables at the paused moment.
Call stackThe sequence of function calls that led to the current execution point.Understand and trace how our JavaScript code is executing at a specific moment in time.
XHR / fetch breakpointsThe exact chain of functions that led to the request when execution pauses on an XHR or fetch() breakpoint.Pause JavaScript execution exactly when a network request is made, so we can see what code triggered the request and why.
DOM breakpointsWhen and where JavaScript code changes the DOM, and what code caused that change.Pause when DOM nodes change (subtree, attributes, removal).
Global listenersA list of registered event types and handlers.Perform a check when events fire from unexpected places.
Event listener breakpointsThe point when specific event categories fire (click, input, keydown, etc).Pause execution when an event fires, so we can see which code responds to the event and why it behaves the way it does.
CSP violation breakpointsWhen a page violates its Content Security Policy (CSP).Pause when Content Security Policy blocks a script/resource. Useful for debugging blocked scripts or workers.
OverridesLocal versions of files (HTML, CSS, JS, images, headers, or responses) that replace what the browser normally loads from the server, without changing the real backend or source code.Replace loaded files with local versions to test changes without redeploying.
Source mapsThe relationship between transformed code (minified, bundled, or transpiled) and the original source code that developers actually wrote.Map bundled or minified code back to original source files.

If you want to dig into async-related behavior that’s harder to reason about step-by-step, no problem: we’ve got a whole dedicated post on Asynchronous JavaScript.


Network Panel

What we use the Network Panel for

The Network panel allows us to inspect all network activity triggered by a web page. It’s particularly useful when when requests fail, data looks incorrect, or page loading feels slow or inconsistent.

We can utilise this panel to show how resources are requested, transferred and loaded, making it easier to understand what the browser is downloading and how long each step takes. Crucially it reflects real requests made by the page, including API calls, static assets, and background fetches.

AreaWhat it showsWe use this to…
Request listAll network requests made by the page.See what loads and in what order.
HeadersRequest and response headers.Debug authentication, caching or CORS issues.
Payload tabData sent with requests.Perform a check when API calls fail or send unexpected values.
ResponseServer responses.Perform an inspection to verify returned data or error messages.
TimingA detailed timing breakdown.Identify slow or blocking requests.
WaterfallA visual request timeline.Spot bottlenecks and load dependencies
ThrottlingSimulate slower network conditions.Test how the page behaves on poor connections.
Offline modeDisabled network access.Verify offline or fallback behavior.

The Payload Tab on the Network Panel

The Payload tab shows the data sent with a network request. Specifically, it lets us inspect query parameters, form data, and request bodies exactly as they are transmitted by the browser.

This view is especially useful when API requests fail, return unexpected results, or behave differently than expected. By checking the payload, we can confirm the data that was actually sent, not what we assumed was sent.

The Payload tab helps us quickly answer “are we sending the right data to the server?” before looking at responses, headers, or backend logic.

CORS errors

CORS errors tell us that a browser request was blocked due to cross-origin rules. This is vital to our wider debugging strategy, as it allows us to answer “why did the browser block this request?” and determine whether the fix belongs in server headers, request setup, or environment configuration.

These errors occur when a web page tries to access a resource from a different origin that is not explicitly allowed by the server. In the Network panel, we can identify CORS issues by inspecting failed requests, their response headers, and related error messages.


Performance Panel

What we use the Performance Panel for

The Performance Panel gives us great insights when our page feels slow, clunky or unstable, even if no actual errors are reported.

Its functionality enables us to record, measure, and analyze how a page loads and responds to user interactions; the insights also give us a vital window on the main thread, revealing why rendering feels slow and isolating the parts of the page that cause visual or interaction issues.

Unlike Network or Console, this panel focuses on time-based behavior: rendering, scripting, layout and user experience metrics, all observed while the page runs.

AreaWhat it showsWe use it to…
Live metricsThe entry point of the panel.Show local performance metrics while interacting with the page.
Largest Contentful Paint (LCP)The time taken by the main visible content to finish loading.Check when pages feel slow to appear.
Cumulative Layout Shift (CLS)Unexpected layout movement.Take a check when elements jump or shift during load.
Interaction to Next Paint (INP)Responsiveness to user input.Check when clicks or typing feel delayed.
LCP element referenceWhich DOM element defines LCP.Identify slow images or blocks.
Interactions / Layout shifts tabsA breakdown of user actions and visual instability events over time.Examine our users’ behavior and evaluate our site’s response.
Screenshots toggleVisual frames during recording.Correlate timing with what we actually see.
Memory toggleMemory usage data in addition to the regular recording.Check performance degrades over time.
Dim 3rd partiesWhich scripts and resources come from third-party domains (visually de-emphasized for extra clarity).Isolate the impact of our own code.
Environment settingsThe conditions under which the performance recording was captured (with specific reference to timing, CPU usage and rendering behavior).Configure CPU and network throttling to simulate real user conditions.
Disable network cacheOnly fresh network requests fetched directly from the server (not responses served from the browser’s cache).Test cold-load performance.
RecordA detailed timeline of everything the browser does while the page runs.Analyze where time is spent and why the page feels slow or clunky.
Record and reloadEverything the browser does during a full page load, from the moment navigation starts until the page finishes loading.Analyze and optimize real page-load performance, not just interactions after the page is already open.

Want a step-by-step walkthrough focused on performance analysis? See How to debug your site performance with Chrome


Memory Panel

What we use the Memory Panel for

As our websites scale, it’s normal for memory usage to increase over time, pages to slow down after extended use and crashes to occur during long sessions. This is where the Memory Panel comes into its own.

The Memory Panel helps us analyze how JavaScript and DOM objects are allocated, retained and released at runtime. It is particularly good at detecting memory leaks, understanding long-lived objects and investigating the memory creep that we might miss ourselves.

Note that the panel focuses on profiling strategies first, then execution context, and only afterward on results and analysis. This mirrors the actual DevTools layout and flow.

AreaWhat it showsWe use it to…
Selecting profiling typeThe kind of memory analysis Chrome will capture and display.Choose how memory is analyzed (actually this should be the first decision we make before running any analysis).
Heap snapshotA point-in-time snapshot of all JavaScript objects and related DOM nodes.Find retained objects and compare snapshots.
Allocations on timelineHow JavaScript memory is allocated and released over time while the app runs.Track allocations over time and highlights objects still alive, which is particularly useful when memory grows during interactions.
Allocation samplingA low-overhead sampling of allocation hotspots.Spot general sources of memory usage.
Detached elementsDOM nodes removed from the document but still retained by JavaScript references,Isolate and identify problems with DOM nodes, a common source of UI memory leaks.
Select JavaScript VM instanceHow Chrome (via the V8 engine) has isolated, executed, and managed JavaScript code.Inspect specific execution contexts (main page, iframe, worker); critical when issues occur only in specific contexts.
Profile results viewThe collected data, based on the selected profiling type.Turn low-level execution data into actionable insights about cost, hotspots, and bottlenecks.
Total JS heap size indicatorA live view of current heap size and growth rate.Observe and track JavaScript memory usage over time, mainly to spot abnormal memory growth and potential leaks.
Load profile / Take snapshot controlsHow JavaScript uses CPU or memory, captured at a specific moment or over a period of time.Start or load a memory analysis session.

Application Panel

What we use the Application Panel for

The Application Panel is crucial for diagnosing persistence, caching, and PWA-related issues. It’s particularly useful when issues are not visible in the UI or console, but come from the less obvious stuff: the cached data, service workers, storage, or app configuration.

With the Application Panel, we get a glimpse of how the browser retains state and manages app-level features. This in turn allows us to focus on how our app stores data, registers background capabilities and defines installable or progressive features.

AreaWhat it showsWe use it when…
ManifestWeb app metadata such as name, icons, theme, and installability.The PWA install or appearance is incorrect.
Service workersRegistered service workers and lifecycle state.Offline support, caching, or updates behave unexpectedly.
Storage (Client-side)Local, session, IndexedDB, cookie and cache storage.Data seems stale, missing, or inconsistent.
Background servicesPush, sync, fetch, and scheduled tasks.Background actions fail or don’t trigger.
Back/forward cachePage restoration behavior.Navigation state breaks after back or forward actions.
FramesStorage and execution context per frame.We’re dealing with iframes or embedded content.

Before we move on to the next panel, let’s dig into some of these areas as they’re essential to navigating the Application Panel properly.

Manifest

The Manifest section controls how our web app is identified and installed by the browser. It shows the parsed contents of manifest.json exactly as Chrome understands it.

If an install prompt is missing or the installed app looks wrong, this is the first place to check.

We typically use Manifest to:

  • Verify app identity, specifically the app name, short name and description used during installation.
  • Check installability, notably whether the app meets PWA requirements and can be installed.
  • Inspect visual presentation. We’re referring to icons, theme color, background color and display mode.
  • Validate startup behavior, looking closely at the start URL and orientation when the app is launched.

Service Workers

Service Workers shows how our app behaves offline and in the background. Specifically, it exposes the lifecycle and state of registered service workers for the current site.

When offline mode does not work, updates are stuck, or cached content behaves unexpectedly, this is the first place to investigate.

We typically use Service Workers to:

  • Verify service worker registration, specifically active, waiting, or redundant workers.
  • Control caching behavior, inspecting and debugging assets cached for offline use.
  • Test updates safely, with the power to skip waiting, unregister or force updates during development.
  • Debug background features like push notifications, background sync and fetch handling.

Storage

When our app behaves correctly after a refresh but breaks later, Storage is often the root cause and the fastest place to confirm it.

Using Storage, we can inspect and manage all client-side data stored by the browser for the current site. The really cool thing is that it groups multiple storage mechanisms in one place.

We typically use Storage to:

  • Inspect stored data like local Storage, Session Storage, IndexedDB and cookies.
  • Verify persisted state: user settings, feature flags and auth tokens.
  • Debug data-related issues such as stale values, unexpected persistence or missing keys.
  • Clear storage safely, without redeploying or hard refreshes.

Privacy and Security

The Privacy and Security Panel is essential when something “should work” but fails due to browser privacy rules or origin security context. It shows how the browser applies privacy restrictions and origin security to the current page.

Armed with this data, we can confirm whether failures come from cookie limits, third-party contexts or insecure origins, not from our code.

Area (where)What it showsWe use it when…
Controls (Left)Privacy controls navigation.We need to test cookie restrictions quickly.
Third-party cookiesCookie restriction settings and scenarios.We want to check embeds, login flows or analytics break.
Security overviewHigh-level security state.The browser shows warnings or blocked content.
Secure originsList of origins and whether they’re secure.Third-party origins or iframes behave differently.
Controls (Right)The actual toggles and exception.We want to reproduce third-party cookie issues without leaving DevTools.

Lighthouse

The Lighthouse Panel is really useful when we need a high-level health check of a page, especially before releases or when tracking regressions across performance, accessibility, and SEO.

Importantly, it lets run automated audits against the current page and get actionable reports across performance, accessibility, best practices and SEO. It simulates real usage conditions and produces a structured report that highlights issues, explains impact, and suggests concrete fixes.

Area (where)What it showsWe use it to…
ModeNavigation, Timespan, or Snapshot.Perform a full-page load audit, check interaction over time or consult a static state.
DeviceMobile or Desktop simulation;Compare mobile-first vs desktop behavior.
CategoriesPerformance, Accessibility, Best Practices, SEO.Select what we want Lighthouse to audit.
Analyze page load (top right)The start of the audit.Reload or inspect the page automatically.
Report view (after run)Scores, metrics, and recommendations.Identify bottlenecks and prioritize fixes.
Diagnostics and opportunitiesDetailed findings with explanations and estimated impact.Identify priorities and decide what to fix first.

A quick note on device emulation

Device emulation lets us simulate how a page renders and behaves on different devices directly inside the browser. It affects viewport size, input method, pixel density, and performance constraints, making it ideal for fast mobile checks without external tools or real devices.

We typically use device emulation to reproduce mobile-only bugs, validate responsive layouts, and test real-world performance conditions without leaving DevTools.

How to open it

  • Click the tablet + phone icon in the top-left corner of DevTools, just above the Elements panel, or
  • Press Cmd + Shift + M (macOS) / Ctrl + Shift + M (Windows).

Once enabled, the page switches into responsive mode, and a device toolbar appears above the page preview.

ControlWhat we use it for
Device selectorSwitch between preset devices (iPhone, Pixel, iPad).
Viewport size fieldsManually set width and height to test breakpoints.
Rotate buttonToggle portrait / landscape orientation.
Device pixel ratio (DPR)Inspect high-DPI rendering issues.
Zoom levelCheck layout scaling and text readability.
Touch simulationEnable touch input for gesture testing.
Network throttlingSimulate slow mobile networks (3G, 4G).
CPU throttlingReproduce performance issues on weaker devices.
Hide device frameFocus purely on content and layout.

More Chrome DevTools

Some Chrome DevTools panels are not visible by default and are grouped under More tools.

These are task-specific utilities that we open only when we need deeper inspection, performance analysis, or experimental features, and they live inside the DevTools drawer at the bottom.

How to open these tools

  • Open DevTools.
  • Click the ⋮ (three-dot menu) in the top-right corner.
  • Select More tools.
  • Choose the panel you need from the list.

Note: tools open as new tabs inside DevTools and stay available for the current session.

ToolWhat we use it for
AI assistance (Chrome only)Get inline explanations, fixes, and suggestions for CSS, performance, and accessibility issues.
AnimationsInspect and debug CSS animations and transitions frame by frame.
AutofillTest and debug browser autofill behavior for forms.
ChangesTrack CSS and DOM edits made in DevTools and copy them back to code.
CoverageFind unused CSS and JavaScript to reduce bundle size.
CSS OverviewGet a global snapshot of CSS usage, colors, fonts, and contrast.
Developer resourcesInspect loaded resources, source maps, and attribution.
IssuesGet a centralized view of browser-detected problems (CSP, deprecations, breaking changes).
LayersInspect composited layers created by transforms and animations.
MediaDebug media playback, codecs, and streaming issues.
Memory inspectorInspect raw memory buffers and low-level memory.
Network conditionsSimulate offline mode, latency, and bandwidth limits.
Network request blockingBlock requests to test failure and fallback scenarios.
Performance monitorView live CPU, memory, DOM nodes, and FPS metrics.
Privacy and security (Top-level panel)Inspect privacy controls, cookie behavior, and secure origins.
Quick sourceOpen ephemeral or evaluated sources.
RecorderRecord and replay user interaction flows.
RenderingEnable visual debugging overlays (paint flashing, layout shifts, FPS).
SearchRun project-wide search across all loaded sources.
SensorsEmulate geolocation, orientation, and motion sensors.
WebAudioDebug Web Audio API graphs and nodes.
WebAuthnTest passkeys and authentication flows.
What’s newView DevTools release notes and recent updates.

We’re not going to explore all of these tools. That’s beyond the scope of a single article. Instead, let’s look at the tools which are most likely to impact your daily work.

CSS Overview

CSS Overview gives us a high-level audit of all CSS used on the page. Instead of inspecting styles element-by-element, it summarizes colors, fonts, unused declarations and potential consistency issues in one place.

We typically use CSS Overview to clean up bloated stylesheets, enforce design consistency, and spot CSS problems early, before they spiral into layout or maintenance issues.

How to open it

  • Open Command Menu with Cmd + Shift + P (macOS) / Ctrl + Shift + P (Windows).
  • Type “CSS Overview” and select Show CSS Overview.
  • Click Capture overview to scan the current page.
SectionWhat we use it for
ColorsIdentify duplicate, similar, or inconsistent colors
Font infoReview font families, sizes, weights, and usage spread
Unused declarationsSpot CSS rules not applied to any element
Media queriesUnderstand responsive breakpoints in use
Z-index valuesDetect stacking conflicts and excessive z-index usage
Specificity issuesFind rules that may be overly specific or hard to override
Overview summaryGet a quick CSS health check of the page

Recorder Panel

The Recorder Panel complements other panels by handling what users do, while the rest of DevTools explains what the browser does in response.

We use Recorder to record clicks, inputs, and navigations exactly as they happen in the browser, empowering us to capture real user interactions and turn them into replayable, repeatable steps. Instead of manually reproducing the same sequence every time, we can record once and reuse, making it easier to debug multi-step issues, validate fixes, or create automated tests from real behavior.

Recorder is especially useful when issues:

  • Require multiple steps to reproduce.
  • Depend on timing or interaction order.
  • Need to be shared clearly with other developers.
  • Should be turned into automated regression tests.
AreaWhat it showsWe use it to…
Plus icon (top left)Create a new recording.Capture a fresh user flow.
Record controlSpecific interactions like clicks, typing and navigation.Begins recording interactions on the page.
Recording list (top bar)Saved recordings.Switch between different flows.
Replay controlsHow a recorded user flow is played back and managed step by step.Re-run a recording to reproduce the same behavior consistently.
Export optionsHow a recorded user flow can be converted and reused outside the Recorder.Convert recordings into Playwright tests or reusable JSON steps.
Create recording (center)The starting point for creating a new user flow.Quick entry point when no recordings exist yet.

The panel focuses on flows and actions, not logs, DOM inspection or metrics.

Rendering Panel

The Rendering panel exposes visual rendering controls that affect how the page is painted and composited by the browser. It’s mainly used to simulate rendering conditions and surface visual issues that are otherwise hard to spot during normal inspection.

We typically open the Rendering panel when visual behavior looks correct in code but wrong on screen, or when validating accessibility, motion preferences, and rendering performance together. Note that it lives in the bottom drawer and is enabled via ⋮ → More tools → Rendering.

ControlWhat we use it for
Emulate CSS media featuresTest media queries like prefers-color-scheme, prefers-reduced-motion, or forced-colors
Dark mode emulationVerify dark / light theme behavior without changing OS settings
Emulate vision deficienciesPreview how the UI appears with color blindness or low vision
Highlight paint flashingDetect frequent repaints that may hurt performance
Highlight layout shiftsSpot unexpected layout movement during page load
Show FPS meterMonitor frame rate stability during animations or scrolling
Show layer bordersVisualize composited layers and rendering boundaries

Dark mode emulation

Dark mode emulation allows us to simulate how a site behaves when the operating system is in dark mode. It triggers the prefers-color-scheme media query, letting us verify styles without changing system settings.

We can deploy dark mode emulation to fulfil a variety of specific functions: to validate theme support, catch contrast issues early, and ensure dark mode behaves correctly across components before shipping.

ControlWhat we use it for
prefers-color-schemeTest dark vs light theme styles.
Live toggleInstantly switch themes without reloading.
CSS media queryValidate @media (prefers-color-scheme) rules.
Color contrastCatch low-contrast text or icons.
Background colorsDetect hardcoded light backgrounds.
Images & iconsSpot assets that don’t adapt to dark mode.

How to open it

  • Open Command Menu with Cmd + Shift + P (macOS) / Ctrl + Shift + P (Windows).
  • Search for “Rendering” and select Show Rendering.
  • In the Rendering panel (bottom drawer), set Emulate CSS media feature → prefers-color-scheme.
  • Choose Dark (or Light to switch back).

Chrome DevTools Settings

We access DevTools settings to shape how powerful and efficient DevTools feels day-to-day, especially when working across multiple projects or devices.Specifically, we can configure how DevTools behaves, enable optional features, and customize our debugging environment.

This panel doesn’t inspect a page directly. It controls preferences, devices, experiments, and AI-assisted features that affect all DevTools panels.

How to open DevTools settings

  • Open Chrome DevTools.
  • Click the gear icon (⚙) in the top-right corner.
  • or open the Command Menu (Cmd + Shift + P / Ctrl + Shift + P) and search for Settings.
SectionWhat we use it for
PreferencesUI behavior, theme, language, panel defaults, and general DevTools behavior.
WorkspaceMap local files to DevTools so changes persist to disk.
AI innovations (Chrome only)Enable AI assistance, console insights, code suggestions and performance annotations.
ExperimentsTurn on experimental features (for example, the Font Editor).
Ignore listExclude files or libraries from debugging and stack traces.
DevicesManage custom devices used in device emulation.
ThrottlingCreate custom network and CPU throttling profiles.
LocationsDefine custom geolocations for location-based testing.
ShortcutsView and customize keyboard shortcuts for DevTools.

Preferences

The Preferences section controls how Chrome DevTools behaves while we inspect and debug a page.

These settings don’t affect the website itself. Instead, they influence how information is displayed, recorded and assisted inside DevTools, helping to reduce noise and adapt the tools to different workflows.

SectionWhat it controls
AppearanceDevTools theme, panel layout behavior, UI language and visual readability options.
NetworkCache behavior, grouping of network activity, and how requests are logged and preserved.
SourcesSource maps, file discovery, editor helpers and debugging behavior for JavaScript.
ConsoleLog verbosity, grouping, timestamps, autocomplete behavior and warning visibility.
PerformanceHow flamecharts and performance visualizations are displayed during profiling.
DevicesCustom device definitions, used by device emulation for responsive testing.
ThrottlingCPU and network throttling profiles used across DevTools panels.
LocationsGeolocation overrides to simulate different physical locations.
WorkspaceMapping local files so edits persist and reflect live during development.

Keep in mind that preferences are usually configured once per workflow, making everyday debugging faster and more predictable.

AI Innovations

The AI Innovations section groups experimental AI-powered features designed to speed up debugging, analysis, and code understanding directly inside DevTools. These tools assist with interpreting console output, inspecting styles and network data, annotating performance traces, and suggesting code while typing.

FeatureWhat it helps with
Console InsightsExplains console warnings and errors
AI assistanceInterprets CSS, network activity, files and performance data
Auto annotationsAdds readable labels to performance traces
Code suggestionsSuggests code in Console and Sources

Things to consider

  • Some inspected data (console messages, stack traces, files, traces) may be sent for analysis.
  • Data may be reviewed by humans to improve the feature.
  • We shouldn’t enable on pages with sensitive or personal information.
  • Features may vary by region or rollout stage.

These tools are best used as assistive helpers, not as a replacement for manual debugging.

Experiments

The Experiments section lets us enable early, in-progress DevTools features before they become stable. This area is primarily for testing, debugging, and exploring advanced capabilities, and it can change frequently as Chrome evolves.

Some commonly useful experiments include:

  • New Font Editor, which unlocks visual font controls in the Styles pane.
  • Full accessibility tree view, which exposes the complete accessibility structure.
  • Capture node creation stacks, which helps trace where DOM nodes are created.
  • Hide ignore-listed code, which reduces noise in the Sources panel.
  • APCA contrast algorithm, which previews upcoming accessibility contrast rules.
  • Protocol Monitor, which inspects low-level DevTools protocol traffic.

Because these features are experimental, be aware that options may disappear, move, or change behavior between releases. We’d advise enabling only what you actively need, and expect this section to evolve quickly over time.

Final thoughts: where DevTools stop, real debugging begins

Chrome DevTools are excellent for local inspection and top-level debugging. They help us understand what’s happening in the browser right now, so we can make fast fixes and pinpoint urgent issues.

But they stop being effective when issues:

  • Happen only in production.
  • Depend on real users, devices or timing.
  • Are intermittent or asynchronous.
  • Disappear when we try to reproduce them locally.

This is what Bugfender is for.

Bugfender complements DevTools with remote, persistent logs, real-world context, and visibility into issues that never show up locally. If DevTools are the microscope, Bugfender is the black box recorder.

So remember: Use DevTools to inspect. Use Bugfender to see what actually happens in the wild.

]]>
How to Read and Analyze iOS Crash Reports: A Developer’s Guide https://bugfender.com/blog/ios-crash-reports/ Mon, 09 Feb 2026 06:58:55 +0000 https://bugfender.com/?p=12984 Continue reading "How to Read and Analyze iOS Crash Reports: A Developer’s Guide"]]> What are iOS crash reports?

Think of an iOS crash report like the black box on an airplane, giving you vital clues whenever your app runs into problems. Understanding iOS crash reports will help you put your app back together, learn from the root cause and ensure it doesn’t happen again.

But what exactly is a crash? Well, a crash can refer to any forced termination of your app caused by an unhandled exception. In other words, anything the app can’t deal with.

So today we’ll give you a firm grasp of iOS crash reports. Not just analysing your reports after the fact, but using the in-built functionality of your crash reporting and logging tools to proactively prevent problems.

We’ll also help you identify the right iOS crash reporting tool for your business, so you’re only paying for stuff you’ll actually use.

Common reasons for iOS crashes

From reading iOS crash reports at Bugfender, we know that a big chunk of iOS crashes are caused by a small number of everyday problems. Let’s dig into those before we go any further.

Index out of range

This is a particularly common exception type. It occurs when we try to access an index on any collection that does not exist. You can easily reproduce an index out of range instance with the following code:

let array = ["1"]
print(array[2])

Force unwrapping

Using force unwrapis a rogue way to code. We’re basically telling the compiler that we’re sure an optional value isn’t nil, and it can crash if we’re wrong. Too reckless, too simplistic. Here’s a safer alternative:

var optionalValue: String? = nil
print(optionalValue!)

Dividing by zero

If we fail to put any checks into our code, it’s possible that we will come to a division and the divisor will be 0.

func divideTenBy(divisor: Int) -> Int {
	return 10 / divisor;
}
...

divideTenBy(0)

Invalid casting of types

Unrelated types are types that have no inheritance or conversion relationship. They can seriously scramble the runtime, particularly when we force out a cast to an unrelated type.

    let a = 10
        
    print(a as! String)

We’ll come back to each of these issues and give you some simple tips to stop them. For now though, let’s look at how to put some robust reporting structures in place, starting with the stack trace, the most important analytics data in our toolkit.

Part 1: How to collect crash reports and logs on ios devices

iOS crash reports from TestFlight and App Stores

If your app is in beta, TestFlight gives you crash reports from your testers. Once your app is live, you’ll get reports from real users via App Store Connect.

Both sources are super helpful for spotting bugs early – or for tracking down those annoying edge cases that only pop up in the wild.

Symbolication

Symbolication is the translation of a crash’s information from memory addresses to a more human-readable format. Want to see an example? Why sure.

Option A

Thread 0 Crashed:
0   App     0x0000000103478abc 0x103470000 + 35516
1   App     0x0000000103478f32 0x103470000 + 36658

Option B

Thread 0 Crashed:
0   App     0x0000000103478abc -[ViewController viewDidLoad] + 40
1   App     0x0000000103478f32 myScreen + 22

Which view do you prefer? It’s obviously B, right?! B gives us a clear view of where things went wrong, while A is just an unreadable stack.

How does symbolication work?

To symbolicate crashes, we need Debug Symbol (dSYM) files. These are generated in each build of our app. You can download these dSYM files from App Store Connect by going:

Your App > TestFlight tab > Choose the build you’d like to have dSYMs for > Build Metadata tab > Download dSYMs button.

Once you have your dSYM, you can symbolicate it to a more readable version.

Symbolicating a dSYM

Xcode is the preferred way to symbolicate crash reports because it uses all available dSYM files on your machine at once.

To symbolicate in Xcode, click the Device Logs button in the Devices and Simulators window, which you can access from the Windows menu button on top. Then you just need to drag and drop the crash report file into the list of device logs.

iOS crash reports from customers

Now let’s zoom in a bit more. If you have physical access to an iOS device that has crashed, you can directly access the logs on the device to unlock priceless debugging information.

Mac OS X

On Mac OS X, you can access the logs easily. First open up Xcode, then open Devices and Simulators on the Window Menu:

Here we can choose a device and drill into the device’s logs:

Now we’ve got access to the stack trace, just like we saw earlier.

Windows

Although the vast, vast majority of iOS developers will be using Macs, it is possible to build iOS apps using Windows, thanks to cross-platform technologies like Flutter, React Native and Unity.

Unfortunately, Windows can’t help us much with our debugging regime. You’ll need a Mac and Xcode to build and analyze iOS apps, and crash logs.

iPhone crash logs

As an alternative to the methods above, you can ask your users to send the iPhone crash logs to you, or get them yourself if you have access. You can also get crash reports for your beta versions through TestFlight. Otherwise, you will need an external service that collects crash data and sends it to you.

Which is where our very own Bugfender comes in…

iOS crash reports with Bugfender

Yep, we’ve got to bring our own solution to the table here. And not because we want to promote ourselves (ok, that’s kinda the reason) but Bugfender offers exactly the kind of proactive defense that we’ve been talking about.

Bugfender offers an iOS SDK that helps you go beyond default crash logs. It lets you:

  • Get full device info for any crash.
  • See what the user was doing right before the crash (including UI interactions).
  • Capture detailed context that helps explain why things broke.

If you want more info than Apple provides by default, then Bugfender really is your friend.

But that’s enough self-promo. We’ve talked about how to get the info, now let’s look at how to read it.

Part 2: How to read iOS crash logs

At first glance, iOS crash logs can look like a big block of text. But once you get the hang of them, they’re one of the most powerful tools for tracking down bugs.

Exception Information

The top part of the crash log gives you a snapshot of what happened and where. It’s basically your crash summary.

Here’s what you’ll usually find:

  • Incident Identifier. A unique ID for the crash event.
  • Device Info. The model and iOS version (e.g. iPhone 13, iOS 17.6).
  • App Info. The app name, bundle ID and version number.
  • Process and Path. Where the app was installed and which process crashed.
  • Crash Time. When the app crashed and when it was launched.
  • Exception Type. The type of crash (e.g. SIGABRTEXC_BAD_ACCESS).
  • Exception Codes. Extra details like memory addresses or error codes.
  • Crashed Thread. The thread that caused the crash (this is where you’ll want to look first).

Example:

Process:             MyApp [1234]
Identifier:          com.example.myapp
Version:             2.3.1 (231)
Hardware Model:      iPhone13,3
OS Version:          iOS 17.6 (Build 21G82)
Exception Type:      EXC_BAD_ACCESS (SIGSEGV)
Exception Codes:     KERN_INVALID_ADDRESS at 0x0000000000000000
Crashed Thread:      5

Exception Information

This section helps you understand the root cause of the crash. Or at least gets you close.

Here’s what to look for:

  • Exception Type: The category of crash. Common ones include:
    • EXC_BAD_ACCESS: The app tried to access invalid memory (null pointers, deallocated objects etc).
    • SIGABRT: Usually comes from an uncaught exception or a failed assertion.
    • EXC_CRASH: A forced crash, often triggered on purpose (e.g. using abort()).
  • Exception Codes: These are more technical, showing where the app failed at the memory level.
  • Crashed Thread: This is the thread where the crash happened. Scroll down to find its backtrace.

Quick tips:

  • Seeing 0x0000000000000000 usually means a null pointer was involved.
  • Always symbolicate your crash logs—this turns memory addresses into readable method and file names.
  • Start reading from the top of the crashed thread’s stack trace. That’s usually where things broke.

Backtraces

A backtrace is basically the same as a stack trace: us debuggers just prefer the word ‘backtrace’. This is the most crucial crash report we can obtain, because it tells us exactly what was happening when the crash unfolded.

The backtrace is a stack of method calls that shows how the app got to the crash point. Specifically, it shows us the calls that our iOS app was processing when everything went wrong. With stack traces, we can see who was calling who at the moment of the crash, and pinpoint where the execution stopped.

For example, if you take the previously given example of the invalid casting type and run an app with it, you will get something akin to:

The ViewModel.init shows exactly where we had our crash. By looking specifically at this, we can determine what was causing the unhandled exception.

Additional tip. While running your iOS app, you can check or log your stack trace at any time simply by using:

for symbol: String in Thread.callStackSymbols {
    print(symbol)
}

Part 3: How to analyze iOS crash reports

Now the final part of the tutorial – and undoubtedly the most important. We’ve talked about how to access crash reporting data, and how to understand it. Now, we need to analyze it.

It’s one thing to read a tranche of crash data. It’s another to read it with actual literacy, so we can stop the crash from happening in future. But like so many coding problems, we can break it down into step-by-step chunks.

Set the Context: Understand User Behavior and Crash Environment

Before diving into the log, ask: what was the user doing when this happened? What device were they on? Which app version?

Gathering this context is key. You might find that the crash only happens on older devices, in specific regions, or during edge cases like poor network conditions. Tools like analytics platforms or custom logging really help here.

Identify Patterns by Grouping Similar Crashes

Looking at one crash log doesn’t always reveal much. But once you start grouping logs with similar stack traces or error messages, patterns start to show up.

Most crash reporting tools (including Bugfender, Crashlytics, etc.) do this for you, but it’s still good to double-check the groupings in case something subtle is getting overlooked.

Pinpoint the Failure Using Exception and Diagnostic Messages

The exception type (like EXC_BAD_ACCESS) and the termination reason (if available) often point you right at the problem – whether it’s memory management, an unrecognized selector, or an issue with system resources. These lines are easy to skip, but they’re pure gold when we’re debugging.

Trace Execution Flow by Decoding the Backtrace

As mentioned, the backtrace shows us the method calls that led up to the crash point.

Start with the crashed thread. This usually shows the most direct route to the issue. Once your log is symbolicated, you’ll see actual function names instead of raw addresses. Read it from the bottom up to follow the code path your app took before crashing.

Verify Your App Binaries and Frameworks

Sometimes the crash isn’t caused by your code directly. It might come from a third-party library or a mismatched binary. In this case, verifying your app binaries and frameworks can help by confirming that what you think is running is actually running.

Make sure your build is clean, you’ve uploaded the correct dSYM files, and you’re not mixing SDK versions in ways that could cause trouble.

Advanced Analysis: Dive into Registers and Low-Level Data

For hard-to-solve bugs, you may need to dive into the low-level details: registers, memory state, thread dumps, etc. It’s not fun, but it’s sometimes the only way to fix really tricky issues, especially those involving multithreading or memory corruption.

Master all that, and your crash report will look like clues, not just numbers.

Part 4: How to Avoid Crashes

Remember those common crash causes we mentioned at the top? Well, there’s a way we can stop them. In fact we can do more than stop them, we can prevent them.

Defensive coding means problems don’t happen, and it should be baked in to our debugging and error management regime. By putting the following steps in place, we can ensure that we’re preventing basic glitches and can focus our attention on more sophisticated threats.

Index out of range

Checking the index beforehand is the best way to avoid an inexistent index. In our case, if we wanted to still be able to access the index 2, here’s how we’d do it:

let array = ["1"]

if(array.count >= 3) {
   print(array[2])
} else {
...
}

Force unwrapping

As mentioned at the top, this is a reckless way to build code. There are many ways to avoid force-unwrapping, including guards , *if lets* and default values:

var optionalValue: String? = nil

guard let value = optionalValue else { return }

print(value)

---//---

if let value = optionalValue { 
	print(value) 
}

---//---

print(optionalValue ?? "")

Dividing by zero

One of the approaches we can take is to check whether the divisor is 0. If it is, just return 0.

func divideTenBy(divisor: Int) -> Int {
	
	return divisor > 0 ? (10 / divisor) : 0;
}
...

divideTenBy(0)

Invalid Casting of Types

We should always check for the type *with is.*

    let a = 10
    if(a is String) {
	    print(a as! String)
    }
    

Use Bugfender to Prevent Crashes

We’re going to talk about ourselves a bit more now, hope that’s cool. Because we’ve specifically built Bugfender to be a proactive defense shield, not just a crash reporting tool.

By using Bugfender early in your development pipeline you can get an early view of your crashes and solve them, before they start to cause mischief with your end users.

For Bugfender to automatically collect your crash information, you can simply turn it on by using:

Bugfender.enableCrashReporting()

In Summary: What to Know About iOS Crash Reports

it is near impossible to have an app without crash occurrences, especially in the early stages of the build. However there are ways to avoid crashes using defensive coding, and also ways to figure out where the issues lie.

What’s more, there are platforms we can use to help us track those issues and have an overview of how often/when they occur to help us mitigate them and fix them as fast as possible.

Hopefully this article has helped you on your way to understanding iOS crashes, and given you the insight you need to mount a proper defense. But you want any more info on how to analyze a crash report and compare the different crash report tools on the market, we’d be happy to help.

Happy coding!

]]>